Writing Automated Tests
The type system catches many mistakes; tests catch the ones that still compile.
<!-- hal:authoritative:yaml -->
*The type system catches many mistakes; tests catch the ones that still compile. A #[test] is a function that fails by panicking (or by returning Err) when the code under test does not match intent.*
§I — Frame
Duha session 12. TRPL Chapter 11 is one syllabus row, so this fire takes the whole chapter at chapter pace. Session 11 left you defining traits, bounding generics, and naming lifetimes. Keep that toolkit; today you prove behavior the compiler cannot see.
Dijkstra’s line opens the chapter: testing shows the presence of bugs, not their absence. Rust still ships a test runner because intent is not a type. add_two that returns the parameter plus 10 still type-checks. Tests are how you refuse that.
Three moves land by the end:
- Write a test —
#[test], assert macros,should_panic, andResult-returning tests. - Run a test —
cargo testdefaults, filters,--ignored, parallel vs serial. - Organize tests — unit tests beside the code (
#[cfg(test)]), integration tests undertests/, doc-tests in comments.
Syllabus Done-criteria: cargo test runs a unit test, an integration test, and a doc-test.
§II — Anatomy of a test
cargo new adder --lib scaffolds a library with a tests module already annotated. The pattern is three steps: arrange state, call the code, assert the result.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
#[test] marks the function for the runner. Helper functions in the same module stay unmarked. #[cfg(test)] keeps the module out of cargo build and out of the release artifact; it compiles only when you run cargo test.
Assert macros:
assert!(expr)— panics whenexpris false.assert_eq!(left, right)/assert_ne!(left, right)— compare withPartialEqand print both sides on failure. Order is left/right in the message; either order is valid if you know which is expected.- Custom messages: add format args after the condition (
assert!(x, "got {x}")).
A failed assertion panics. The runner catches that panic and marks the test failed. Passing tests capture stdout; failed tests show it. Use cargo test -- --show-output when you want prints from green tests too.
§III — Panic expectations and Result tests
Some APIs should panic on bad input. Mark the test with #[should_panic] so a panic is success. Narrow it with #[should_panic(expected = "…")] so a different panic still fails the test.
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "between 1 and 100")]
fn greater_than_100() {
Guess::new(200);
}
}
Prefer returning Result from production code (session 10). Tests can return Result<(), E> too:
#[test]
fn it_works() -> Result<(), String> {
if 2 + 2 == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
Then ? works inside the test body. Do not mix #[should_panic] with Result-returning tests; pick one failure path.
§IV — Controlling the run
cargo test runs unit tests, then integration tests, then doc-tests. Defaults matter:
- Parallel by default — each test may run on its own thread. Do not share mutable files or env without isolation. Force serial with
cargo test -- --test-threads=1. - Filter by name —
cargo test addruns every test whose name containsadd. Module names participate, so you can filter a whole module. - Ignore expensive tests —
#[ignore]skips them in the normal run. Bring them back withcargo test -- --ignored(or--include-ignored).
Anything after -- goes to the test binary, not to Cargo. That is why --show-output and --test-threads sit after the double dash.
§V — Unit, integration, doc
Unit tests live in src/ next to the code, usually in a tests module with #[cfg(test)]. Child modules see private items via use super::*, so you can test private helpers when that is the right cut.
Integration tests live in a top-level tests/ directory. Each .rs file is its own crate. You import only the public API:
// tests/integration_test.rs
use adder::add_two;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
No #[cfg(test)] is required there. Cargo only builds tests/ for cargo test. Shared helpers go in tests/common/mod.rs (a module, not a separate test crate) so Cargo does not treat the helper file as another integration binary.
Binary-only crates get integration tests by extracting a library the binary calls; otherwise there is no public API for tests/ to import.
Doc-tests are Rust examples in /// documentation comments. cargo test compiles and runs them under the Doc-tests section. They document and verify at once. Keep examples small and focused on the public surface.
§VI — One complete proof
- In a library crate, add a unit test under
#[cfg(test)] mod teststhat usesassert_eq!on a public function. - Add
tests/integration_test.rsthat imports the crate and asserts through the public API. - Add a short
///example on a public function and confirmcargo testreports a Doc-tests run. - Say aloud: unit tests may see private items; integration tests may not; doc-tests are public examples the runner executes.
When those four hold, Chapter 11’s selected depth is done.
§VII — Closing
Tests do not replace the type system; they cover intent the types leave open. Write with #[test] and assert macros. Prefer Result in production and in tests when failure is data, not panic. Know the run knobs: parallel default, name filters, #[ignore]. Split unit, integration, and doc so each layer answers a different question. Session 11’s generics stay underneath every assert_eq! on a generic type; the I/O project is the next unmarked row.
Done-criteria: cargo test runs a unit test, an integration test, and a doc-test.