SuperAPI AI Directory

rust-impl-testing

Testing & quality

View repository

Updated 10-16 06:18

About this skill

rust-impl-testing

The Rust testing skill: how to write unit tests, integration tests, and doc tests; how Cargo's three test kinds map to file layout; how to use #[should_panic], #[ignore], and Result<(), E>-returning tests; how to run async tests with #[tokio::test]; when to switch from cargo test to cargo nextest run; and how to add property, snapshot, and benchmark testing on stable Rust.

Cross-references: [[rust-impl-cargo-project]] (the [profile.test] profile, [dev-dependencies], the test and bench fields), [[rust-impl-async-tokio]] (#[tokio::test] runtime selection), [[rust-syntax-async-await]] (calling .await in test bodies).


When to use this skill

  • User writes #[test] functions, or wraps them in #[cfg(test)] mod tests { ... }.
  • User asks where integration tests go (src/, tests/, examples/?).
  • User writes doc tests in /// comments and one of them stops compiling.
  • User wants negative tests via #[should_panic] or wants to skip slow tests via #[ignore].
  • User writes an async fn test and gets a runtime panic.
  • User wants a faster runner than cargo test, or per-test process isolation.
  • User wants property-based, snapshot, or micro-benchmark coverage on stable Rust.

For Cargo's project layout, [dev-dependencies], and the [profile.test] / [profile.bench] profiles, see [[rust-impl-cargo-project]]. For tokio runtime details under #[tokio::test], see [[rust-impl-async-tokio]].


Decision tree: which kind of test belongs where

What do you want to test?
   A private function or module internals
      -> Unit test in src/, under `#[cfg(test)] mod tests { use super::*; ... }`

   The crate's public API as an external consumer would use it
      -> Integration test in tests/<name>.rs (each file is its own crate)

   The exact code shown in a doc comment compiles and runs
      -> Doc test in /// comments inside the item it documents
         Only available for `lib` crates; for `bin` crates, move logic to lib.rs

   Behaviour that depends on async code (.await)
      -> #[tokio::test] (or async-std equivalent), not bare #[test]

   A panic is the success condition (precondition violations, etc.)
      -> #[should_panic(expected = "substring of panic message")]

   A statistical / many-input check
      -> proptest! { ... } or quickcheck

   Pretty-printed regression of a value
      -> insta::assert_snapshot! / assert_yaml_snapshot!

   Micro-benchmark on stable
      -> criterion in benches/, NOT the unstable #[bench] attribute

ALWAYS keep unit tests in the same file as the code they test, gated by #[cfg(test)]. NEVER place unit tests for module internals inside tests/: tests/ only has access to the public API.


Anatomy of a unit test

The convention is one tests submodule per source file:

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

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

    #[test]
    fn adds_two_positives() {
        assert_eq!(add(2, 2), 4);
    }
}

Rules:

  • ALWAYS gate the test module with #[cfg(test)] so production builds skip compilation, dependencies, and symbols.
  • ALWAYS use super::*; so the test module sees private items of the parent module.
  • The test runner discovers any function annotated with #[test]; the function MUST take no arguments.

Assertion macros (assert!, assert_eq!, assert_ne!) accept an optional format-string message:

#[test]
fn greeting_contains_name() {
    let result = greeting("Carol");
    assert!(
        result.contains("Carol"),
        "Greeting did not contain name, value was `{result}`"
    );
}

For tests that should return errors instead of panicking, return Result<(), E> and use ?:

#[test]
fn parses_input() -> Result<(), std::num::ParseIntError> {
    let n: i32 = "42".parse()?;
    assert_eq!(n, 42);
    Ok(())
}

NEVER combine Result<T, E> return with #[should_panic]: the two are mutually exclusive. To assert an Err value, use assert!(value.is_err()); in a normal -> () test.


Anatomy of an integration test

Integration tests live in the top-level tests/ directory next to src/. Each .rs file is compiled as its own crate, with the crate-under-test brought in via use mycrate::....

my_crate/
  Cargo.toml
  src/
    lib.rs
  tests/
    integration_test.rs
    common/
      mod.rs
// tests/integration_test.rs
use my_crate::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();
    assert_eq!(add_two(2), 4);
}

Rules:

  • ALWAYS put shared helpers in tests/common/mod.rs, NEVER in tests/common.rs. A bare tests/common.rs is compiled as a separate integration-test binary and shows up in the test summary as an empty running 0 tests line.
  • ALWAYS access the crate-under-test through its public API. There is no super::* available; the integration test sits outside the library.
  • For binary crates (src/main.rs only), integration tests cannot call into the binary directly. ALWAYS move logic into src/lib.rs and let main.rs be a thin wrapper, then integration-test the library.

Doc tests

Code in triple-backtick blocks inside /// comments is compiled and run by cargo test --doc. Rustdoc wraps each block in an implicit fn main() and an implicit extern crate <yourcrate>; (unless #![doc(test(no_crate_inject))] is set), so simple examples need no boilerplate:

/// Adds two to its argument.
///
/// # Examples
///
/// ```
/// let n = my_crate::add_two(3);
/// assert_eq!(n, 5);
/// ```
pub fn add_two(x: i32) -> i32 { x + 2 }

Hide setup with #-prefixed lines (they are compiled and executed, but not rendered in the docs):

/// ```
/// # use my_crate::*;
/// let n = add_two(3);
/// assert_eq!(n, 5);
/// ```

Doc-test code-fence info-strings control behaviour:

Info-string Effect
(none) / rust Compile and run the example.
ignore Skip both compilation and execution. Last resort; prefer the others.
no_run Compile but do not run (good for networked or never-terminating code).
should_panic The block MUST panic at runtime to pass.
compile_fail The block MUST fail to compile to pass.
edition2024 Compile this block under the named edition.

Use ? inside doc tests by hiding a main that returns a Result:

/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let n: i32 = "42".parse()?;
/// assert_eq!(n, 42);
/// # Ok(()) }
/// ```

Doc tests run only for library targets. ALWAYS test binary logic through a library crate; doc tests on src/main.rs are not executed.


#[should_panic] and #[ignore]

#[should_panic] inverts success: the test passes if (and only if) the body panics. Without an expected = "..." substring it matches any panic, including unrelated bugs that happened to fire first.

#[test]
#[should_panic(expected = "less than or equal to 100")]
fn rejects_out_of_range() {
    Guess::new(200);
}

ALWAYS pass expected = "..." to #[should_panic] so the test still fails when an unrelated panic masks the one you meant to catch. NEVER combine #[should_panic] with -> Result<(), E>.

#[ignore] skips a test by default (typically slow or environment-dependent):

#[test]
#[ignore]
fn expensive_integration() { /* ... */ }

Invocations:

  • cargo test runs everything except ignored tests.
  • cargo test -- --ignored runs only the ignored tests.
  • cargo test -- --include-ignored runs all tests, ignored or not.

Running tests: the cargo CLI surface

The synopsis is cargo test [options] [testname] [-- test-options]. Arguments before -- go to Cargo (target selection); arguments after -- go to the libtest harness (filtering, threads, output).

Goal Command
Run all tests (lib + bin + integration + doc) cargo test
Only library unit tests cargo test --lib
Only integration tests cargo test --tests
One specific integration-test file cargo test --test integration_test
Only doc tests cargo test --doc
Filter by name substring cargo test parse
Show stdout of passing tests cargo test -- --show-output
Single-threaded execution cargo test -- --test-threads=1
Run ignored tests only cargo test -- --ignored
Run ignored + non-ignored cargo test -- --include-ignored

ALWAYS use --test-threads=1 when tests share mutable global state (env vars, current dir, on-disk files in a fixed path). Better: redesign the test to use a tempfile::TempDir so parallelism is safe.


Async tests

A plain #[test] does not start a runtime; an async fn test will fail to compile or panic at first .await. Use the runtime's test attribute instead.

#[tokio::test]
async fn fetches_data() {
    let value = my_service::fetch().await.unwrap();
    assert_eq!(value, 42);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn fetches_data_multithreaded() { /* ... */ }

ALWAYS depend on tokio = { version = "1", features = ["macros", "rt"] } (or rt-multi-thread for the multi-threaded flavor) in [dev-dependencies]. See [[rust-impl-async-tokio]] for runtime details.


cargo nextest

cargo nextest is a faster, process-isolated test runner. Each test runs in its own subprocess, which catches leaked global state and produces clearer failure reports. It does NOT run doc tests; ALWAYS pair it with a separate cargo test --doc step in CI.

# Install (Linux/macOS pre-built binary into ~/.cargo/bin):
curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin
# Or:
cargo binstall cargo-nextest --secure

# Run:
cargo nextest run                 # all (non-doc) tests
cargo nextest run --workspace     # every member crate
cargo nextest run -E 'test(parse)'   # filterset DSL

Per-profile configuration lives in .config/nextest.toml. For a typical CI step, configure retries = 2 on flaky integration tests and keep doc tests in a separate cargo test --doc job.


Property, snapshot, and benchmark testing

See references/methods.md for the full lookup tables. Quick map:

  • proptest (proptest! macro, strategies, shrinking) and quickcheck (#[quickcheck]): generate inputs automatically and shrink failing cases. ALWAYS pin a seed file or PROPTEST_CASES count for reproducible CI.
  • insta (insta::assert_snapshot!, assert_yaml_snapshot!): records the printed form of a value to a .snap file; review changes with cargo insta review. ALWAYS check .snap files into version control.
  • criterion: stable-Rust micro-benchmark framework. Put benches under benches/, declare them in Cargo.toml as [[bench]] name = "..." harness = false. NEVER use the built-in #[bench] attribute: it is permanently unstable and only available on nightly with #![feature(test)].

Common test layout for a library crate

my_crate/
  Cargo.toml             # [dev-dependencies] for tokio/proptest/insta/criterion
  src/
    lib.rs               # pub items, with #[cfg(test)] mod tests in each file
    parser.rs
  tests/
    api.rs               # public-API integration test
    common/
      mod.rs             # shared helpers, NOT tests/common.rs
  benches/
    parse.rs             # criterion bench, harness = false

ALWAYS keep this shape; tooling, IDE integrations, and cargo defaults assume it.


See also

  • references/methods.md: full table of cargo test / cargo nextest flags, doc-test attributes, and dev-dependency setup snippets.
  • references/examples.md: end-to-end example crates for unit + integration + doc + async + property + snapshot + criterion bench.
  • references/anti-patterns.md: the seven anti-patterns this skill prevents, with quoted fix-its.
  • [[rust-impl-cargo-project]] for [dev-dependencies], [[test]], [[bench]], and [profile.test].
  • [[rust-impl-async-tokio]] for #[tokio::test] flavors and runtime configuration.
  • [[rust-syntax-async-await]] for .await semantics in test bodies.

Sources (verified 2026-05-19):