Skip to content
← All articles

#[test], the assert macros, and what a test actually is

Testing is a language feature in Rust, not a library convention — which is why there is no runner to configure and why tests can see your private functions. Also: why this site cannot grade `#[test]` at all.

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]. A Result-returning test cannot also be marked should_panic; the compiler rejects it.
  • Do not write assert!(result.is_ok()). When it fails you learn nothing except that something went wrong. Return the Result, or unwrap() it — both print the actual error. clippy::assertions_on_result_states says 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.