In most languages, testing is a library. You add a dependency, you learn its decorators, you configure a runner, and you keep a parallel directory tree that the build system has to be told about.
In Rust testing is part of the language. #[test] is a built-in
attribute; cargo test needs no configuration file; tests live next to the
code they test and can see its private items. That last one is not a
convenience hack — it falls straight out of the privacy model from 10.3, and
it is one of the genuinely nice things about the language.
What a test actually is
A #[test] function is an ordinary function that takes no arguments. When you
run cargo test, Cargo compiles your crate a second time with
rustc --test, which sets the test cfg flag and links in a small runner
called libtest. libtest collects every #[test] function, calls them, and
reports.
How does it know a test failed? The function panicked. That is the entire protocol.
#[test]
fn two_plus_two() {
assert_eq!(2 + 2, 4);
}
assert_eq! panics when its arguments differ. A test that returns normally
passed; a test that unwound failed. Nothing else is involved.
💡Given that a failing test is just a panic, what happens to a test that calls std::process::exit(0) halfway through — and why is that different from a panic?
click to reveal
The process ends immediately and the whole test binary reports success or
failure based on that exit code, taking every other test in the same binary
down with it. There is no unwinding, so no Drop runs, no catch_unwind
catches it, and libtest never gets control back to record a result.
This is the same distinction you will use directly in item 10.20, where an
exit-based op is how “ignored means not executed” gets graded: catch_unwind
cannot save you from it.
It is also why calling exit inside library code is such a hostile thing to
do. A panic is recoverable at a boundary; an exit is not recoverable anywhere.
The assert macros
Three of them, plus a rule about arguments.
assert!(cond); // panics if cond is false
assert_eq!(left, right); // panics if left != right
assert_ne!(left, right); // panics if left == right
All three take an optional trailing format!-style message:
assert!(v.len() < 10, "vector grew to {} entries", v.len());
assert_eq! and assert_ne! require PartialEq and Debug on the
operands — PartialEq to do the comparison, Debug to print both values when
it fails. That second requirement catches people out with their own types: the
fix is #[derive(Debug)], and if you find yourself reaching for assert!(a == b) to dodge it, you have just given up the failure message that makes the
test worth having.
Their panic payload is worth memorising, because item 10.10 asks you to reproduce it byte for byte:
assertion `left == right` failed
left: 4
right: 5
There is also debug_assert! and friends, compiled out entirely unless
debug_assertions is on. Use them for expensive internal invariants, never
for validating input you actually received — a check that vanishes in release
is not a check.
#[should_panic], and its sharp edge
#[test]
#[should_panic(expected = "denominator")]
fn rejects_zero() {
Ratio::new(1, 0).unwrap();
}
The test passes only if the body panics, and expected is matched as a
substring of the panic message.
💡Why is #[should_panic] without expected considered a smell, to the point that clippy has a lint for it?
click to reveal
Because it passes on any panic, including one that has nothing to do with what you meant to test.
Picture a test that builds a fixture, indexes into a vector, and then calls the function under test. You refactor, the fixture shrinks, the index goes out of bounds, and the test panics on line two — before it ever reaches the thing it is supposedly checking. It still passes. It will keep passing forever, green and worthless.
clippy::should_panic_without_expect (pedantic) exists for exactly this. Write
expected = "…" with enough of the message to be specific, and accept that it
couples the test to wording — that coupling is the price of the test actually
testing something.
Tests that return Result
A test may return Result<(), E> instead of (), which lets you use ?:
#[test]
fn parses() -> Result<(), std::num::ParseIntError> {
let n: i32 = "42".parse()?;
assert_eq!(n, 42);
Ok(())
}
An Err return counts as a failure. Two things to know:
-
This is mutually exclusive with
#[should_panic]. AResult-returning test cannot also be markedshould_panic; the compiler rejects it. -
Do not write
assert!(result.is_ok()). When it fails you learn nothing except that something went wrong. Return theResult, orunwrap()it — both print the actual error.clippy::assertions_on_result_statessays this.
Lints that fire in tests, and one that cannot
Test code is code, and -D warnings applies to it in a normal project. The
ones you will meet:
| lint | catches |
|---|---|
bool_assert_comparison |
assert_eq!(x, true) — use assert!(x) |
assertions_on_constants |
assert!(true), assert!(false) |
assertions_on_result_states |
assert!(r.is_ok()) |
eq_op |
assert_eq!(x, x) — always true, tests nothing |
missing_assert_message (pedantic) |
asserts with no explanatory message |
should_panic_without_expect (pedantic) |
as above |
tests_outside_test_module (restriction) |
#[test] fns not in a mod tests |
The reason this is an article and not a problem
cfg(test) is set by rustc --test. This site compiles submissions with
plain rustc -O. So if you write this in a submission:
#[cfg(test)]
mod tests {
#[test]
fn definitely_broken() {
assert_eq!(1, 2);
}
}
the entire block is removed before type-checking. It compiles cleanly, it
never runs, clippy never looks inside it — bool_assert_comparison does not
fire in there, verified. The grader would tell you that you passed.
Grading #[test] here is therefore impossible, and pretending otherwise would
teach you to write tests that are never executed. The honest alternative is
the next item: build the runner yourself. By the end of 10.20 you will
have implemented #[ignore], #[should_panic(expected = …)], substring
matching and panic-payload recovery, which is a far better grip on what
#[test] is doing than twenty annotated functions would have given you.