Testing & Benchmarking

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Rust 1.99.0, the 2024 edition, criterion 0.8, proptest 1.11 and insta 1.49, checked on 2026-10-06. Rust has testing built into the language and Cargo: annotate a function with #[test], run cargo test, and Cargo builds a test binary, runs every test in parallel and reports the results. This page covers the built-in harness, the three kinds of tests, benchmarks, and the most common ecosystem tools. Testing command-line programs end to end (assert_cmd, trycmd) is covered on Testing CLIs; testing axum handlers on Axum: Testing.

Unit tests

Unit tests live next to the code they test, in a module marked #[cfg(test)] so it is compiled only for cargo test. use super::*; brings the parent module’s items — including private ones — into scope:

pub fn late_fee_cents(days_late: u32) -> u32 {
    days_late.min(10) * 50
}

fn is_valid_isbn13(digits: &str) -> bool {      // private: still testable from the child module
    digits.len() == 13 && digits.bytes().all(|b| b.is_ascii_digit())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn fee_is_capped_at_ten_days() {
        assert_eq!(late_fee_cents(3), 150);
        assert_eq!(late_fee_cents(30), 500, "fee must be capped"); // optional message
    }

    #[test]
    fn rejects_short_isbn() {
        assert!(!is_valid_isbn13("978"));
        assert_ne!(late_fee_cents(1), 0);
    }

    #[test]
    #[should_panic(expected = "attempt to multiply with overflow")]
    fn overflow_panics_in_debug() {
        let _ = late_fee_cents(5) * u32::MAX; // only panics in builds with overflow checks
    }

    #[test]
    fn parses_year() -> Result<(), std::num::ParseIntError> {
        let year: u16 = "1965".parse()?;   // `?` in tests: an Err fails the test
        assert_eq!(year, 1965);
        Ok(())
    }

    #[test]
    #[ignore = "needs a running catalogue service"]
    fn talks_to_remote_catalogue() {}

    #[test]
    fn lookup_matches_pattern() {
        let r: Result<u16, String> = Ok(1965);
        std::assert_matches!(r, Ok(1900..=1999)); // 1.96: prints the value on failure
    }
}
Macro / attribute Meaning

assert!(cond, "msg {x}")

fail if cond is false

assert_eq!(a, b) / assert_ne!(a, b)

compare with ==; on failure print both values with Debug

std::assert_matches!(value, pattern)

fail unless the value matches the pattern (1.96)

debug_assert! and friends

checked only when debug assertions are on (not in release builds)

#[should_panic(expected = "...")]

the test passes only if it panics with a message containing the text

#[ignore] / #[ignore = "reason"]

skipped unless run with --ignored or --include-ignored

a test returning Result<(), E>

Err fails the test; lets you use ?

Running tests: cargo test

cargo test                          # build and run unit, integration and doc tests
cargo test fee                      # only tests whose name contains "fee"
cargo test tests::parses_year -- --exact
cargo test -- --nocapture           # show println! output of passing tests (alias: --show-output for passing ones)
cargo test -- --test-threads=1      # run sequentially (tests that share files or ports)
cargo test -- --ignored             # run only ignored tests; --include-ignored runs all
cargo test --lib                    # only unit tests in the library
cargo test --test catalogue         # only tests/catalogue.rs
cargo test --doc                    # only documentation tests
cargo test --release                # optimised build (note: no overflow checks)

Arguments before -- go to Cargo; arguments after -- go to the test binary. Tests run in parallel threads in the same process, so they must not depend on each other or on shared global state (environment variables, the current directory, fixed file names or ports) unless they synchronise.

Integration tests

Files in a package’s tests/ directory are integration tests: each one is compiled as a separate crate that uses your library like any external user — only its public API is reachable. Shared helpers go in tests/common/mod.rs (not tests/common.rs, which would be treated as a test file itself).

shelf-core/
├── src/lib.rs
└── tests/
    ├── common/
    │   └── mod.rs            # shared fixtures: pub fn sample_catalog() -> InMemoryCatalog
    └── lending.rs            # an integration test crate
// tests/lending.rs
use shelf_core::{Author, Book, Catalog, CatalogError, InMemoryCatalog, Isbn};

fn sample_catalog() -> InMemoryCatalog {
    let mut c = InMemoryCatalog::new();
    let isbn = Isbn::parse("978-0-441-01359-3").unwrap();
    c.add(Book::new(isbn, "Dune", vec![Author::new("Frank Herbert")], 1965)).unwrap();
    c
}

#[test]
fn a_book_cannot_be_lent_twice() {
    let mut c = sample_catalog();
    let isbn = Isbn::parse("9780441013593").unwrap();
    c.lend(&isbn, "alice").unwrap();
    assert_eq!(c.lend(&isbn, "bob").unwrap_err(), CatalogError::AlreadyLent(isbn));
}

Binary crates (only main.rs) cannot be imported by integration tests — one more reason to put logic in lib.rs.

Documentation tests

Code blocks in /// doc comments are compiled and run as tests by cargo test, which keeps examples in documentation correct. Since the 2024 edition, rustdoc compiles all doctests of a crate into a single binary where possible (combined doctests), which makes cargo test --doc much faster; a doctest that must run on its own (for example because it uses a #![feature] or depends on process-global state) is marked standalone_crate. Doctest attributes such as no_run, should_panic, compile_fail and hidden # lines are covered on Documentation with rustdoc.

Organising tests and testing private functions

The Book’s guidance: unit tests in #[cfg(test)] mod tests beside the code, integration tests in tests/, and the public API covered by both. Rust lets unit tests call private functions (they are in a child module); whether you should is a design choice — testing through the public API makes refactoring easier, while testing a tricky private helper directly gives faster, more precise failures. Test-only helpers can be gated with #[cfg(test)], and test-only dependencies go in [dev-dependencies].

Benchmarks with criterion

The built-in #[bench] attribute and test::Bencher are still nightly-only. On stable, use the criterion crate, which runs each benchmark many times, removes outliers, reports confidence intervals and compares with the previous run. Benchmarks live in benches/ and need harness = false:

[dev-dependencies]
criterion = "0.8"

[[bench]]
name = "isbn"
harness = false
// benches/isbn.rs
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;

fn check_digit(digits: &[u8; 12]) -> u8 {
    let sum: u32 = digits.iter().enumerate().map(|(i, &d)| u32::from(d) * if i % 2 == 0 { 1 } else { 3 }).sum();
    ((10 - sum % 10) % 10) as u8
}

fn bench_isbn(c: &mut Criterion) {
    let prefix = [9, 7, 8, 0, 4, 4, 1, 0, 1, 3, 5, 9];
    c.bench_function("isbn check digit", |b| b.iter(|| check_digit(black_box(&prefix))));
}

criterion_group!(benches, bench_isbn);
criterion_main!(benches);

Run with cargo bench (or cargo bench — isbn to filter). std::hint::black_box stops the optimiser from computing the result at compile time or discarding it. Mara & Holmes (Refactoring to Rust, chapter 6) benchmark Rust against the code it replaces with criterion; compare both implementations in the same benchmark group so the report shows the ratio. divan and iai-callgrind (instruction counts, stable in CI) are popular alternatives. Profiling is covered on Performance & Profiling.

Testing Rust from other languages

When Rust replaces part of a Python, C or JavaScript code base, the existing test suite of the host language is the best regression check: build the Rust code as an extension or library and run the old tests against it. Mara & Holmes (chapter 7) test a PyO3 module with pytest; the same idea applies to a cdylib called from a C test harness or a WebAssembly module tested from JavaScript. See Python Interop with PyO3 and Incremental Adoption & Refactoring.

Property testing with proptest

Property-based testing generates many random inputs and checks that a property holds for all of them; when one fails, proptest shrinks it to a minimal counter-example. It is excellent for parsers, encoders and anything with an inverse:

fn normalise(isbn: &str) -> String {
    isbn.chars().filter(char::is_ascii_digit).collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn normalising_is_idempotent(s in "[0-9-]{0,20}") {
            let once = normalise(&s);
            prop_assert_eq!(normalise(&once), once);
        }

        #[test]
        fn keeps_every_digit(digits in proptest::collection::vec(0u8..10, 0..13)) {
            let text: String = digits.iter().map(|d| format!("{d}-")).collect();
            prop_assert_eq!(normalise(&text).len(), digits.len());
        }
    }
}

Snapshot testing with insta

Snapshot tests store the expected output of a function in a file the first time they run, then compare against it; cargo insta review shows diffs interactively and accepts changes. They suit large or structured outputs: rendered reports, JSON responses, error messages, CLI help text:

fn render_report(titles: &[&str]) -> String {
    titles.iter().enumerate().map(|(i, t)| format!("{:>2}. {t}\n", i + 1)).collect()
}

#[cfg(test)]
mod tests {
    #[test]
    fn report_layout() {
        insta::assert_snapshot!(super::render_report(&["Dune", "Emma"]), @r"
         1. Dune
         2. Emma
        ");
    }
}

The @"..." form keeps the snapshot inline in the source; without it, insta writes snapshots/*.snap files to commit alongside the tests.

cargo-nextest and coverage

  • cargo-nextest (cargo install cargo-nextest, then cargo nextest run) runs each test in its own process, in parallel, with better output, retries for flaky tests and JUnit reports for CI. It does not run doctests; keep cargo test --doc for those.

  • cargo-llvm-cov uses the compiler’s LLVM source-based coverage: cargo llvm-cov --html (or --lcov for CI services) measures line and region coverage of tests, and works with nextest (cargo llvm-cov nextest).

cargo nextest run --workspace
cargo llvm-cov --workspace --lcov --output-path lcov.info

Source: cargo-nextest

Where the books are outdated

Mara & Holmes (Refactoring to Rust, chapter 6) use criterion 0.3; current criterion (0.8) keeps the same structure but uses std::hint::black_box (stable since 1.66) rather than criterion::black_box. assert_matches! (1.96) and combined doctests (2024) are newer than all the books.