rgoussu@goussu: ~/library/rust/testing
~/library/rust/testing cat unit-testing.md

Unit testing — the built-in harness, organization & doubles

# Rust unit testing with #[test] and #[cfg(test)] modules — assertion macros, rstest parameterization, insta snapshots, doctests, and the trait-based approach to test doubles.

Conceptsaved 2026-08-09 #rust#testing#mocking#insta#rstest

Overview

Rust's unit-testing story is stdlib-minimal like Go's: #[test] functions, assert! macros, and a convention — the #[cfg(test)] mod tests block at the bottom of the file under test, compiled only for tests and with access to private items. The community adds parameterization (rstest), snapshot testing (insta) and mocking (mockall), but the center of gravity stays plain functions asserting on plain values — the type system having already eliminated the null/type-confusion checks other ecosystems test by hand.

Key points

  • #[cfg(test)] mod tests beside the code is the unit-test home: private access, zero cost in shipping builds. use super::*; is the standard opener.
  • Tests can return Result: fn parses() -> Result<(), ParseError> lets ? replace unwrap-chains — cleaner failure paths than panicking asserts.
  • Assertion macros are the API: assert!, assert_eq!/assert_ne! (with a format-args trailing message), #[should_panic(expected = "…")], matches! for enum-shape checks. pretty_assertions upgrades assert_eq! failures to colored diffs — a one-line dev-dependency almost every project carries.
  • rstest brings parameterized cases (#[case(…)]) and fixtures — Rust's answer to JUnit's @ParameterizedTest and Go's table-driven idiom (plain loops over structs work too and remain common).
  • insta snapshots: assert_snapshot!/assert_json_snapshot! store expected output in .snap files, cargo insta review approves diffs — the golden-file workflow with tooling, ideal for rendered output, error messages, IR dumps.
  • Doctests are unit tests too: every /// example runs under cargo test — put the canonical usage example there instead of duplicating it in a test module — see rustdoc.
  • Doubles are trait-shaped: define the dependency as a trait, hand-write an in-memory fake, inject via generics (fn new(clock: impl Clock)) — monomorphized, no dynamic dispatch needed. mockall generates expectation-based mocks (#[automock]) for interaction-heavy boundaries; the hand-rolled-fake-first culture matches Go's, not Mockito's.
  • Time and randomness are dependencies: inject a clock trait or use tokio::time::pause() in async tests — sleeping in tests is a flake factory in any language.
  • Run it with nextest; cargo test -- --nocapture when you need println output; #[ignore] + -- --ignored for the slow shelf.

Examples

pub fn parse_duration(s: &str) -> Result<Duration, ParseError> { /* … */ }

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

    #[rstest]
    #[case("15s", Duration::from_secs(15))]
    #[case("1h30m", Duration::from_secs(5400))]
    fn parses_valid(#[case] input: &str, #[case] want: Duration) {
        assert_eq!(parse_duration(input).unwrap(), want);
    }

    #[test]
    fn rejects_garbage() {
        assert!(matches!(parse_duration("soon"), Err(ParseError::Invalid(_))));
    }
}

Related