21 — Testing in Rust
Rust's testing is built into the language and cargo. There are three layers: unit tests, integration tests, and documentation tests.
Test Categories
- Unit tests: live inside the source, in
#[cfg(test)] mod tests. Test private items. - Integration tests: live in
tests/as separate crates. Test public API. - Doc tests: code in
///doc comments, run as examples.
Writing a Unit Test
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two() {
assert_eq!(add(2, 2), 4);
}
#[test]
#[should_panic(expected = "negative")]
fn rejects_negative() {
panic!("negative not allowed");
}
#[test]
fn returns_result() -> Result<(), String> {
if add(2, 2) == 4 { Ok(()) } else { Err(String::from("bad")) }
}
}
#[test]marks a test function.assert!,assert_eq!,assert_ne!.#[should_panic(expected = "...")]for panic tests.- Returning
Result<(), E: Debug>is allowed —Errfails the test.
Running Tests
cargo test # all tests
cargo test add # filter by name substring
cargo test --lib # only lib unit tests
cargo test --test integration # only specific integration test
cargo test -- --nocapture # show println! output
cargo test -- --test-threads=4
cargo test --release # tests in release mode
cargo test -- --ignored # run #[ignore] tests
Integration Tests
tests/
└── integration_test.rs
use my_crate::add;
#[test]
fn integration_add() {
assert_eq!(add(2, 3), 5);
}
- Separate crate; can only use
pubAPI. - Multiple files in
tests/become separate test binaries.
Common Setup Module
tests/
├── common/
│ └── mod.rs # NOT a test file
└── integration_test.rs # use mod common;
Files in tests/common/ (without a top-level test fn) are helpers, not test binaries.
Doc Tests
/// Adds two numbers.
///
/// # Examples
///
/// ```
/// use my_crate::add;
/// assert_eq!(add(2, 2), 4);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
- Compiled and run by
cargo test. - Must compile as a separate binary.
- Hidden imports via
#:
/// ```
/// # use my_crate::add;
/// assert_eq!(add(2, 2), 4);
/// ```
The # line is hidden from rendered docs but included when testing.
Skipping Doc Tests
/// ```no_run
/// loop { /* don't actually run */ }
/// ```
///
/// ```ignore
/// let x = todo!();
/// ```
no_run compiles but doesn't execute. ignore skips compilation. compile_fail asserts the snippet fails to compile (negative tests). rust,no_run etc. customize.
Assertions Cheat Sheet
assert!(cond);
assert!(cond, "custom message {x}");
assert_eq!(a, b);
assert_eq!(a, b, "msg");
assert_ne!(a, b);
debug_assert!(cond); // only in debug builds
debug_assert_eq!(a, b);
For Result-returning assertions, use the matches style or .unwrap()/? with Result-returning tests.
#[should_panic]
#[test]
#[should_panic]
fn panics() { panic!(); }
#[test]
#[should_panic(expected = "exact substring")]
fn panics_specifically() { panic!("exact substring here"); }
#[ignore]
#[test]
#[ignore = "slow, run manually"]
fn slow_test() { /* ... */ }
Skipped unless --ignored is passed.
Asynchronous Tests
#[tokio::test]
async fn async_test() {
let v = async_fn().await;
assert_eq!(v, 5);
}
Use the runtime's test attribute (tokio::test, async_std::test).
Benchmark Tests (unstable)
// requires nightly or use the `criterion` crate
#![feature(test)]
extern crate test;
use test::Bencher;
#[bench]
fn bench_add(b: &mut Bencher) {
b.iter(|| add(test::black_box(2), test::black_box(2)));
}
criterion is the de facto stable benchmarking tool.
Property-Based Testing
Use proptest or quickcheck:
proptest! {
#[test]
fn add_commutative(a in -1000i32..1000, b in -1000i32..1000) {
proptest::prop_assert_eq!(add(a, b), add(b, a));
}
}
Snapshot Testing
Use insta:
#[test]
fn snapshot() {
let v = render();
insta::assert_snapshot!(v);
}
Test Organization Tips
- Unit tests inside
#[cfg(test)] mod testsso they don't bloat the production binary. - Don't test private functions if you can test them through the public API.
- Test edge cases: empty, boundary, max/min, overflow, unicode, concurrency.
- Use
mockallor hand-rolled traits for dependency injection.
Common Pitfalls
#[should_panic(expected = ...)]is a substring match, not regex/exact.cargo testruns in parallel by default: shared files / ports can race. Use--test-threads=1or unique tempdirs.- Tests in
bin/files: put#[cfg(test)] mod testsinmain.rs/bin/x.rstoo. - Doc tests slow: many crates skip them in CI for speed (
cargo test --lib --bins --tests). unwrap()in tests is fine: tests aren't production code; panicking is OK.- Floating point equality: use
approxcrate orassert!((a - b).abs() < EPS). cargo testshows only failures by default: use--nocaptureto seeprintln!output even on success.- Time-sensitive tests: inject a
Clocktrait for deterministic tests.
Coverage
cargo install cargo-tarpaulin
cargo tarpaulin
Or cargo-llvm-cov for source-based coverage.
Fuzzing
Use cargo-fuzz (libFuzzer-based) for finding panics/UB:
cargo install cargo-fuzz
cargo fuzz add parse_target
# edit fuzz/fuzz_targets/parse_target.rs
cargo fuzz run parse_target
Test Traits: Debug for assert_eq!
assert_eq! requires T: PartialEq + Debug. If you see "the trait Debug is not implemented," derive it.
Summary
Tests live alongside code (#[cfg(test)]), in tests/ for integration, and in doc comments for doc tests. Use assert!/assert_eq!/should_panic/#[ignore]. Run with cargo test. Use tokio::test for async. Use proptest/insta/criterion/cargo-fuzz for advanced testing.
Next: Concurrency, threads, and the message-passing vs shared-state story.