Testland
Browse all skills & agents

rust-unit-tests

Rust unit testing with the built-in `cargo test` harness - `#[test]` in `#[cfg(test)] mod tests` blocks, `assert_eq!` / `assert_ne!` / `assert!` macros, `#[should_panic(expected)]`, `Result<(), E>` test returns, integration tests in `tests/`, doc tests in `///` comments, runner flags (`--test-threads=1`, `--nocapture`, `--ignored`), `#[ignore]` marking, coverage via cargo-llvm-cov / tarpaulin, and Criterion benchmarks on stable. Includes framework choice (stdlib `#[test]` is the default; rstest for 4+ parameterized case pairs or shared fixtures via references) and test-authoring conventions (inline `#[cfg(test)]` placement, assertion-macro selection, async runtime requirements). References cover rstest parametrize + fixtures and Rust mocking with mockall (`#[automock]` / `mock!`). Use for any Rust unit-test task: writing tests, testing panics or Results, doc tests, coverage gates, benchmarks, or CI wiring.

Install with skills.sh (any agent)

npx skills add testland/qa --skill rust-unit-tests
View source

rust-unit-tests

Overview

Per doc.rust-lang.org/book/ch11-00-testing.html (opens in new window):

Rust's testing is built into Cargo - the #[test] attribute marks test functions; cargo test discovers and runs them. Three test categories per the Rust Book:

CategoryLocationPurpose
Unit testsSame file as code, in #[cfg(test)] mod tests { ... }Test private + internal logic
Integration teststests/ directory at crate rootTest public API as an external user
Doc testsInside /// doc commentsVerify documentation examples

Choosing a framework

  1. stdlib #[test] is the default - built into the language, no Cargo.toml change needed.
  2. rstest when the spec has 4+ input/output case pairs or setup shared across 3+ tests - #[rstest] + #[case] runs each pair as a named test, discovered by cargo test natively → references/rstest.md. Match an existing rstest convention (rstest in [dev-dependencies] AND #[rstest] usage in tests) rather than introducing it ad hoc.
  3. Mocking trait boundaries → mockall, references/rust-mocking.md.
  4. Property-based invariantsproptest-testing (qa-property-based plugin).

Step 1 - Unit tests

// src/math.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

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

    #[test]
    fn adds_two_numbers() {
        assert_eq!(add(1, 2), 3);
    }
}
cargo test                 # all tests
cargo test add             # filter by name pattern
cargo test --lib           # only unit tests in lib
cargo test --all-targets   # everything
cargo test --workspace     # multi-crate workspace

#[cfg(test)] keeps the module out of release builds.

Step 2 - Assertion macros

assert!(condition, "format message: {}", value);
assert_eq!(actual, expected);
assert_ne!(actual, unexpected);

assert_eq! / assert_ne! print BOTH left and right on failure; bare assert!(x == y) only reports false (rust-test (opens in new window)). For diff-rich struct comparisons, the pretty_assertions crate colorizes the output.

Step 3 - #[should_panic] and Result returns

#[test]
#[should_panic(expected = "negative")]
fn specific_panic_message() {
    sqrt(-1.0);
}

#[test]
fn parses_config() -> Result<(), Box<dyn Error>> {
    let cfg = Config::from_file("test/fixtures/config.toml")?;
    assert_eq!(cfg.port, 8080);
    Ok(())
}

The Result return allows ? in test bodies - a failing ? fails the test with the real error instead of "called unwrap on None".

Step 4 - Integration tests

my-crate/
  src/lib.rs
  tests/
    integration_test.rs    # automatically discovered
    common/mod.rs          # shared helpers (NO mod.rs in tests/ root)

Each file in tests/ compiles to its own binary - slower but better-isolated; only the crate's public API is visible.

Step 5 - Doc tests

/// Adds two numbers.
///
/// # Examples
///
/// ```
/// use my_crate::math::add;
/// assert_eq!(add(1, 2), 3);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }

cargo test --doc runs only doc tests; cargo test runs everything. The example IS the test, so docs can't drift from the implementation.

Step 6 - Runner flags and #[ignore]

cargo test -- --test-threads=1           # serial
cargo test -- --nocapture                # show println! output
cargo test -- --ignored                  # only #[ignore]-marked tests
cargo test -- --include-ignored          # ignored + normal
cargo test some_pattern -- --exact       # exact name match
#[test]
#[ignore = "Requires network access"]
fn integration_with_external_api() { ... }

Always include the = "reason" or ignored tests get forgotten.

Step 7 - Coverage and benchmarks

Coverage needs an extra crate - cargo-llvm-cov (cross-platform, recommended) or cargo-tarpaulin (Linux-only):

cargo install cargo-llvm-cov
cargo llvm-cov --html
cargo llvm-cov --lcov --output-path coverage.lcov
cargo llvm-cov --fail-under-lines 80     # gate at 80%

Benchmarks on stable use Criterion (stdlib #[bench] is nightly-only and breaks CI):

[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "math_bench"
harness = false
// benches/math_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use my_crate::math::add;

fn bench_add(c: &mut Criterion) {
    c.bench_function("add 1 2", |b| b.iter(|| add(black_box(1), black_box(2))));
}

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

Run cargo bench (bheisler.github.io/criterion.rs).

Step 8 - CI integration

- run: cargo test --all-targets --workspace
- run: cargo test --doc
- run: cargo install cargo-llvm-cov
- run: cargo llvm-cov --lcov --output-path coverage.lcov
- uses: codecov/codecov-action@v4
  with: { files: coverage.lcov }

Authoring conventions

When authoring a new unit test in an existing project:

  1. Detect the framework: stdlib #[test] unless rstest is in [dev-dependencies] AND existing tests use #[rstest]. Conflicting signals → stop and ask.
  2. Placement: the conventional unit-test idiom is an inline #[cfg(test)] mod tests block at the end of the source file; use a separate tests/<name>.rs only for public-API integration scenarios (doc.rust-lang.org/cargo/guide/tests).
  3. One spec → one new test function; never modify existing tests, never fabricate symbols the module does not declare, no assert!(true) smoke asserts when the spec names a concrete value.
  4. Async needs a runtime: a bare #[test] fn calling .await does not compile - use #[tokio::test] (when the project depends on Tokio) or rstest's #[future] injection (references/rstest.md).
  5. Refuse universally-quantified specs ("any non-negative price") - property-based scope → proptest-testing (qa-property-based).

Anti-patterns

Anti-patternWhy it failsFix
Skip --all-targetsDoc tests + benches + examples not runAlways --all-targets (Step 1)
unwrap() in test bodiesFailure message is "called unwrap on None"Result<(), E> return + ? (Step 3)
#[ignore] without a reasonForgotten ignored tests= "reason" (Step 6)
assert!(x == y)Loses the value diff on failureassert_eq! (Step 2)
Nightly #[bench] in CIRequires nightly toolchainCriterion on stable (Step 7)

Limitations

  • No fixture concept beyond mod tests shared state (rstest adds fixtures - references/rstest.md).
  • Doc tests compile slowly (each is a separate doctest binary).
  • No mocking in stdlib - mockall is the community standard (references/rust-mocking.md).
  • No parametrize beyond hand-rolled loops or rstest.

References

  • rust-test (opens in new window) - Rust Book Chapter 11 (testing)
  • doc.rust-lang.org/cargo/commands/cargo-test.html - cargo test reference
  • crates.io/crates/cargo-llvm-cov - coverage tool
  • bheisler.github.io/criterion.rs - Criterion docs
  • references/rstest.md - rstest parametrize + fixtures
  • references/rust-mocking.md - mockall
  • go-unit-tests - sister umbrella for Go
  • proptest-testing (qa-property-based) - Rust property-based
  • test-code-conventions (qa-test-review) - test code hygiene

rstest - Rust parametrize + fixtures (reference)

View source (opens in new window)

rstest - Rust parametrize + fixtures (reference)

Companion reference for rust-unit-tests. rstest adds parametrize and fixture patterns that stdlib #[test] lacks; its tests are still discovered and run by cargo test - no separate runner. Use when the same input pattern repeats across many tests, setup is shared across 3+ tests, or migrating pytest/JUnit5 habits to Rust. For single-case tests, plain #[test] needs no extra dependency.

Per github.com/la10736/rstest (opens in new window):

Install

[dev-dependencies]
rstest = "0.21"

Parametrize with #[case]

use rstest::rstest;

#[rstest]
#[case(1, 2, 3)]
#[case(0, 0, 0)]
#[case(-1, 1, 0)]
fn add_cases(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
    assert_eq!(add(a, b), expected);
}

Each #[case] runs as a separate test; failures don't stop subsequent cases. Name the cases - #[case::positive(1, 2, 3)] yields add_cases::positive instead of the debug-hostile case_1.

Fixtures

use rstest::{fixture, rstest};

#[fixture]
fn db() -> Database {
    Database::new_test_instance()
}

#[fixture]
fn user(db: Database) -> User {          // fixtures can chain
    db.create_user("alice")
}

#[rstest]
fn test_user_id(user: User) {            // injected by parameter name
    assert_eq!(user.id, 1);
}

Customize a fixture per test with #[default(...)] on the fixture parameter and #[with(...)] at the call site:

#[fixture]
fn user(#[default("alice")] name: &str) -> User { User::new(name) }

#[rstest]
#[case::bob("bob")]
fn test_user(#[case] expected: &str, #[with(expected)] user: User) {
    assert_eq!(user.name, expected);
}

Matrix tests (cartesian product)

#[rstest]
fn test_matrix(
    #[values("alice", "bob", "charlie")] name: &str,
    #[values(0, 18, 65)] age: u32,
) {
    let user = User::new(name, age);
    assert!(user.is_valid());
}

Runs 3 × 3 = 9 combinations. #[case] and #[values] combine (each case × each value). Watch the explosion - 5 dims × 5 values = 3125 tests; prefer strategic cases over a full matrix.

Async tests

#[rstest]
#[case(1, 2, 3)]
#[tokio::test]
async fn async_add_cases(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
    assert_eq!(add_async(a, b).await, expected);
}

Async fixtures use #[future]:

#[fixture]
async fn db_async() -> Database {
    Database::connect_async().await.unwrap()
}

#[rstest]
#[tokio::test]
async fn test_async(#[future] db_async: Database) {
    let db = db_async.await;
    assert!(db.is_connected());
}

CI

Same as plain cargo: cargo test --all-targets - rstest tests are native cargo test citizens.

Anti-patterns

Anti-patternWhy it failsFix
rstest for single-case testsDependency for no benefitPlain #[test]
Unnamed casescase_1, case_2 in failure logs#[case::name(...)]
Full matrix everywhereCombinatorial explosionStrategic cases
Mixing sync + async in one parametrizeConfusingSeparate #[rstest] blocks

References

Rust mocking - mockall (reference)

View source (opens in new window)

Rust mocking - mockall (reference)

Companion reference for rust-unit-tests. A test double (per ISTQB Glossary (opens in new window)) replaces a real dependency so the subject under test runs in isolation. mockall is the community-standard Rust mocking crate; use it when a unit test reaches a database, HTTP client, file system, or any trait boundary. For tests that do not cross a trait boundary, prefer real objects or simple stubs.

Per docs.rs/mockall/latest/mockall (opens in new window):

[dev-dependencies]
mockall = "0.14.0"

Two entry points: #[automock] for traits you own; mock! for structs or traits defined in external crates.

#[automock] on a trait

Applying #[automock] generates a MockTraitName struct in the same module:

use mockall::automock;
use mockall::predicate::*;

#[automock]
pub trait Cache {
    fn get(&self, key: &str) -> Option<String>;
    fn set(&mut self, key: &str, value: String);
}

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

    #[test]
    fn test_lookup_hits_cache() {
        let mut mock = MockCache::new();

        mock.expect_get()
            .with(eq("user:42"))
            .times(1)
            .returning(|_| Some("alice".to_string()));

        let result = lookup(&mock, "user:42");
        assert_eq!(result, Some("alice".to_string()));
    }
}

Expectations are verified automatically when the mock is dropped: a declared times(1) with no call panics on drop and fails the test.

mock! macro for external traits and structs

Use mock! when the trait lives in a dependency you cannot annotate:

use mockall::mock;

mock! {
    pub HttpClient {}
    impl reqwest_like::Client for HttpClient {
        fn get(&self, url: &str) -> String;
        fn post(&self, url: &str, body: &str) -> String;
    }
}

#[test]
fn test_fetch_uses_get() {
    let mut client = MockHttpClient::new();

    client.expect_get()
        .with(eq("https://api.example.com/v1/data"))
        .times(1)
        .return_once(|_| r#"{"ok":true}"#.to_string());

    let result = fetch_data(&client);
    assert!(result.contains("ok"));
}

Expectation methods

MethodBehaviour
.times(n)Requires exactly n calls
.times(..)Any number (range syntax)
.with(matcher)Argument predicate from mockall::predicate::*
.returning(closure)Computes return value via FnMut
.return_once(closure)Consumes an FnOnce (for non-Clone returns)
.return_const(value)Clones and returns a constant
.never()Asserts the method is never called

Common predicates (mockall::predicate::*): eq(v), ne(v), lt(v), gt(v), function(fn), always(), never().

Anti-patterns

Anti-patternWhy it failsFix
Mock every dependencyTests verify mock wiring, not behaviorMock only true isolation boundaries
Missing #[cfg(test)] on the mock moduleMock types compiled into the release binaryWrap MockXxx usage in #[cfg(test)]
mock! when #[automock] sufficesVerbose boilerplate for owned traits#[automock] for traits in your crate

Limitations

  • #[automock] doesn't support some advanced generic trait patterns - fall back to mock! with explicit type parameters.
  • Expectations verify on drop; with multiple mocks in scope, drop-order panics can produce confusing test output.

References