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-testsrust-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:
| Category | Location | Purpose |
|---|---|---|
| Unit tests | Same file as code, in #[cfg(test)] mod tests { ... } | Test private + internal logic |
| Integration tests | tests/ directory at crate root | Test public API as an external user |
| Doc tests | Inside /// doc comments | Verify documentation examples |
Choosing a framework
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:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Skip --all-targets | Doc tests + benches + examples not run | Always --all-targets (Step 1) |
unwrap() in test bodies | Failure message is "called unwrap on None" | Result<(), E> return + ? (Step 3) |
#[ignore] without a reason | Forgotten ignored tests | = "reason" (Step 6) |
assert!(x == y) | Loses the value diff on failure | assert_eq! (Step 2) |
Nightly #[bench] in CI | Requires nightly toolchain | Criterion on stable (Step 7) |
Limitations
References
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-pattern | Why it fails | Fix |
|---|---|---|
| rstest for single-case tests | Dependency for no benefit | Plain #[test] |
| Unnamed cases | case_1, case_2 in failure logs | #[case::name(...)] |
| Full matrix everywhere | Combinatorial explosion | Strategic cases |
| Mixing sync + async in one parametrize | Confusing | Separate #[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
| Method | Behaviour |
|---|---|
.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-pattern | Why it fails | Fix |
|---|---|---|
| Mock every dependency | Tests verify mock wiring, not behavior | Mock only true isolation boundaries |
Missing #[cfg(test)] on the mock module | Mock types compiled into the release binary | Wrap MockXxx usage in #[cfg(test)] |
mock! when #[automock] suffices | Verbose boilerplate for owned traits | #[automock] for traits in your crate |