crefft Source 2026.10.4

Rust Testing Tools

crefft > Languages > Rust > Rust Testing Tools

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in BCP 14 RFC 2119 RFC 8174 when, and only when, they appear in all capitals, as shown here.

This guide covers the tools that make a Rust test suite trustworthy: coverage, fuzzing, property and mutation testing, public-API compatibility checks, benchmarking, suite throughput, and concurrency permutation testing with Loom. The toolchain, lint, formatting, and pre-commit rules in the Rust Style Guide apply and are not repeated here, and so do the dependency and CI rules; every version quoted is listed in the Tested Version Matrix and was current on 8 September 2026.

Code Coverage

Projects SHOULD measure code coverage. cargo-llvm-cov1 SHOULD be the default; cargo-tarpaulin2 MAY be used where its report formats are required.

Why cargo-llvm-cov

cargo-llvm-cov drives the compiler’s own source-based instrumentation (-C instrument-coverage), so it reports on every target the toolchain supports rather than on the platforms a separate tracing engine has been ported to, and it counts branches the way the compiler sees them. tarpaulin’s default ptrace engine is Linux/x86-64 only.21

cargo install cargo-llvm-cov --version 0.9.1 --locked

cargo llvm-cov --all-features --workspace            # summary
cargo llvm-cov --all-features --html                 # HTML report
cargo llvm-cov --all-features --lcov --output-path lcov.info
cargo llvm-cov --all-features --fail-under-lines 80  # CI gate
# Alternative: cargo-tarpaulin
cargo install cargo-tarpaulin --version 0.37.2 --locked
cargo tarpaulin --out Html --fail-under 80

Coverage targets and the reasoning behind them are in the Testing Guide; this guide covers only the Rust tooling.

Fuzzing: cargo-fuzz

Projects handling untrusted input SHOULD use fuzzing. cargo-fuzz3 SHOULD be the default choice.

Platform Requirements

cargo-fuzz drives libFuzzer4, which needs LLVM sanitizer support. Projects MUST confirm the environment before adopting it:

RequirementSupported
Architecturex86-64 and AArch64 only
Operating systemUnix-like only — not Windows
Toolchainnightly, because the flags it passes are unstable
Host toolinga C++ compiler with C++11 support

Why: these are libFuzzer’s constraints, not cargo-fuzz’s, and they are not detected until link time. A Windows CI job added to a fuzzing matrix fails after the whole dependency tree has been built. Fuzzing jobs MUST be restricted to Linux or macOS runners.3

Setup

rustup toolchain install nightly-2026-09-01
cargo install cargo-fuzz --version 0.13.2 --locked

cargo fuzz init
cargo fuzz add my_target
# fuzz/Cargo.toml
[package]
name = "my_crate-fuzz"
version = "0.0.0"
edition = "2024"
publish = false

[package.metadata]
cargo-fuzz = true

[dependencies]
libfuzzer-sys = "0.4.13"

[dependencies.my_crate]
path = ".."

[[bin]]
name = "my_target"
path = "fuzz_targets/my_target.rs"
test = false
doc = false
bench = false
// fuzz/fuzz_targets/my_target.rs
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        let _ = my_crate::parse(s);
    }
});
# Time-boxed run against a persisted corpus
cargo +nightly-2026-09-01 fuzz run my_target fuzz/corpus/my_target \
  -- -max_total_time=300 -rss_limit_mb=2048 -timeout=10

Fuzzing in CI

The nightly toolchain MUST be pinned by date, the corpus MUST be persisted between runs, and crash artifacts MUST be uploaded.

Why: an unpinned +nightly makes the job fail on unrelated compiler changes with no diff to explain it. A corpus that is discarded each run restarts coverage exploration from zero, so a scheduled job repeatedly re-finds shallow inputs and never reaches the deep ones. A crash that is not uploaded cannot be reproduced.

jobs:
  fuzz:
    runs-on: ubuntu-latest   # libFuzzer does not support Windows
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
        with:
          toolchain: nightly-2026-09-01
      - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        with:
          path: fuzz/corpus
          key: fuzz-corpus-${{ github.sha }}
          restore-keys: fuzz-corpus-
      - run: cargo install cargo-fuzz --version 0.13.2 --locked
      - run: |
          cargo fuzz run my_target fuzz/corpus/my_target \
            -- -max_total_time=300 -rss_limit_mb=2048
      - if: failure()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: fuzz-artifacts
          path: fuzz/artifacts

AFL.rs Alternative

For American Fuzzy Lop5:

cargo install afl --version 0.18.2 --locked
cargo afl build
cargo afl fuzz -i in -o out target/debug/my_target

Property Testing

Projects SHOULD state invariants as property tests where a round trip, an ordering, or an algebraic law can be expressed. proptest6 SHOULD be used.

Why

An example test asserts one point; a property test asserts a rule over a generated domain and shrinks any counterexample to a minimal failing input, which is usually the whole diagnosis. Round trips, idempotence, and ordering laws are the highest-value candidates because they hold for every input by construction.

[dev-dependencies]
proptest = "1.11.0"  # proptest[^32]
// tests/props.rs
use proptest::prelude::*;

proptest! {
    #[test]
    fn render_then_parse_round_trips(values in prop::collection::vec(any::<u32>(), 0..32)) {
        let text = bench_props::render_list(&values);
        prop_assert_eq!(bench_props::parse_list(&text), Ok(values));
    }
}

Failing cases are written to proptest-regressions/ and MUST be committed, so the shrunk counterexample becomes a permanent regression test.

Mutation Testing

Projects SHOULD run cargo-mutants7 to measure whether the test suite actually detects changed behavior.

Why

Coverage says a line ran; it does not say an assertion would have failed had that line been wrong. cargo-mutants replaces function bodies with plausible alternatives and reports the ones the suite still passes with — a missed mutant is a concrete, addressable gap.

cargo install cargo-mutants --version 27.1.0 --locked

cargo mutants --in-diff git.diff   # pull-request scope: only changed code
cargo mutants --package my_crate   # full run, scheduled

A full run is slow because it rebuilds and retests per mutant. Pull requests SHOULD use --in-diff; whole-crate runs SHOULD be scheduled.

Public API Compatibility

Library crates MUST check public API compatibility before publishing. cargo-semver-checks8 MUST run in CI for every published library.

Why

SemVer breakage in Rust is easy to introduce and hard to see in review: adding a variant to a public enum, a field to a public struct, a method to a public trait, or tightening a bound are all breaking, and none of them looks like a removal in the diff. cargo-semver-checks compares the rustdoc JSON of the working tree against the published version and names the rule that was violated.

cargo install cargo-semver-checks --version 0.50.0 --locked

cargo semver-checks                             # against the latest release
cargo semver-checks --baseline-version 1.2.3    # against a specific version
jobs:
  semver:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
        with:
          toolchain: stable
      - run: cargo install cargo-semver-checks --version 0.50.0 --locked
      - run: cargo semver-checks

Version numbering policy is in the Versioning Guide.

Benchmarking

Performance claims MUST be backed by a benchmark harness. Criterion9 SHOULD be used.

Why

cargo bench on stable has no built-in harness — #[bench] remains nightly only — so a stable project without Criterion has no measurement at all. Criterion runs a warm-up, collects a sample distribution, and reports a confidence interval against the previous run, which distinguishes a real regression from runner noise. A single wall-clock timing does not.

[dev-dependencies]
criterion = "0.8.2"  # Criterion[^35]

[[bench]]
name = "parse"
harness = false      # REQUIRED: Criterion supplies its own main

Why harness = false: without it Cargo links libtest’s harness, which owns main, and criterion_main! is never called. The benchmark target then builds and runs zero benchmarks while reporting success.

// benches/parse.rs
use criterion::{Criterion, criterion_group, criterion_main};
use std::hint::black_box;

fn parse_benchmark(c: &mut Criterion) {
    let input = bench_props::render_list(&(0..256).collect::<Vec<_>>());
    c.bench_function("parse_list/256", |b| {
        b.iter(|| bench_props::parse_list(black_box(&input)));
    });
}

criterion_group!(benches, parse_benchmark);
criterion_main!(benches);
cargo bench                       # measure, compare against the last run
cargo bench -- --save-baseline pr # named baseline for a comparison run

Test Performance

Test throughput and code performance are separate concerns and MUST NOT be measured with the same tool. --test-threads changes libtest concurrency; it does not measure anything.

Why

cargo test -- --test-threads=4 sets how many tests run at once. It produces no timing, no distribution, and no comparison, so quoting it as a performance figure measures the runner’s scheduling rather than the code. Worse, lowering it is the standard workaround for tests that share global state, so a project that treats the flag as a performance knob usually ends up hiding a real isolation bug behind it. Suite wall-clock belongs to cargo-nextest10, which runs tests in separate processes; code performance belongs to Criterion9, which reports a confidence interval.

# Suite wall-clock: run tests in parallel processes
cargo nextest run --all-features

# Reduce interference for a flaky test, not a performance measurement
cargo test -- --test-threads=1

# Optimized build: relevant when the code under test is compute-bound
cargo test --release

# Compile tests without running them
cargo test --no-run

Nextest

cargo install cargo-nextest --version 0.9.143 --locked
cargo nextest run --all-features

Nextest10 runs each test in its own process rather than its own thread, so one test cannot corrupt another’s global state, a panicking test cannot take the runner down with it, and per-test timeouts and retries become possible. Nextest does not run doc tests; cargo test --doc MUST still run separately.

Thread Safety Testing

Rust’s ownership system prevents data races at compile time. Most thread safety is proven through types.

Testing Send + Sync Bounds

#[test]
fn test_send_sync() {
    fn is_send<T: Send>() {}
    fn is_sync<T: Sync>() {}

    is_send::<MyType>();
    is_sync::<MyType>();
}

Async Testing with tokio::test

#[tokio::test]  // tokio[^14]
async fn test_concurrent_access() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(tokio::spawn(async move {
            *counter.lock().await += 1;
        }));
    }

    for handle in handles {
        handle.await.unwrap();
    }

    assert_eq!(*counter.lock().await, 10);
}

Concurrency Testing with loom

Loom11 MUST be declared as a cfg(loom) target dependency, the model MUST use Loom’s instrumented synchronization types, and the test MUST be run with --cfg loom set.

Why: Loom cannot observe std::sync::atomic or std::thread. It permutes executions only through its own replacements, so a model written against the standard types compiles, runs once, passes, and proves nothing. Declaring the dependency under [target.'cfg(loom)'.dependencies] keeps Loom and its tracing stack out of ordinary cargo test builds, and gating the test file on cfg(loom) means a normal run reports “0 tests” rather than a false pass. Nothing sets --cfg loom for you.11

# Cargo.toml
[target.'cfg(loom)'.dependencies]
loom = "0.7.2"  # loom[^15]

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(loom)'] }

Why check-cfg: loom is not a known cfg, so without this declaration every #[cfg(loom)] raises an unexpected_cfgs warning, and under -D warnings the ordinary build fails.

// tests/concurrent.rs
#![cfg(loom)]
#![allow(clippy::unwrap_used, reason = "loom model test")]

use loom::sync::Arc;
use loom::sync::atomic::AtomicUsize;
use loom::sync::atomic::Ordering::{Acquire, Relaxed, Release};
use loom::thread;

#[test]
fn increment_is_atomic() {
    loom::model(|| {
        let num = Arc::new(AtomicUsize::new(0));
        let threads: Vec<_> = (0..2)
            .map(|_| {
                let num = Arc::clone(&num);
                thread::spawn(move || {
                    num.fetch_add(1, Release);
                })
            })
            .collect();

        for t in threads {
            t.join().unwrap();
        }

        assert_eq!(2, num.load(Acquire));
        assert_eq!(2, num.load(Relaxed));
    });
}
# Loom runs nothing unless the cfg is set. --release keeps the permutation
# search tractable.
RUSTFLAGS="--cfg loom" cargo test --test concurrent --release
// BAD: std types, no cfg gate, plain `cargo test`. This runs the closure once,
// explores no interleavings, and passes whatever the code does.
#[test]
fn test_concurrent_safety() {
    loom::model(|| {
        let v1 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
        let v2 = v1.clone();
        std::thread::spawn(move || { v1.fetch_add(1, SeqCst); });
        v2.fetch_add(1, SeqCst);
    });
}

Loom does not implement the whole C11 model: SeqCst accesses are treated as AcqRel, which can produce false alarms, and some load-buffering executions are not explored, so a clean Loom run is not a proof.11

References

See Also

Footnotes

  1. cargo-llvm-cov - Cargo subcommand for LLVM source-based code coverage ↩ ↩2

  2. cargo-tarpaulin - Code coverage tool for Rust; the default ptrace engine is Linux/x86-64 only ↩ ↩2

  3. cargo-fuzz - Command-line wrapper for using libFuzzer ↩ ↩2

  4. libFuzzer - Library for coverage-guided fuzz testing ↩

  5. AFL.rs - Rust bindings for American Fuzzy Lop ↩

  6. proptest - Hypothesis-style property testing with shrinking ↩

  7. cargo-mutants - Mutation testing for Rust ↩

  8. cargo-semver-checks - Public API SemVer compliance checking ↩

  9. Criterion - Statistics-driven benchmarking harness ↩ ↩2

  10. cargo-nextest - Next-generation test runner for Rust ↩ ↩2

  11. loom - Concurrency permutation testing tool ↩ ↩2 ↩3