Skip to content
← Back to explainers

Rust explainer

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

About 9 min to read. Allow extra time to work through examples.

What a Rust test function is, how the assert macros and #[should_panic] decide pass or fail, which Clippy lints apply to tests, and why this site cannot grade #[test] functions.

This article uses Rust. You can make it your practice mode to browse related content across the site.

Rust ships its test support with the toolchain. #[test] is an attribute built into the compiler, the runner that executes tests (a library called libtest) comes with every Rust installation, and cargo test finds and runs tests without any extra dependency or runner configuration. Unit tests usually live in the same file as the code they check, and because they sit in a child module of that code they can call its private functions.

This article explains what a test is, what the assert macros check and print, how #[should_panic] and Result-returning tests work, which Clippy lints apply to test code, and why this site cannot grade #[test] functions.

What a test is

A test function is an ordinary function marked #[test]. It takes no arguments; the compiler rejects one that does ("functions used as tests can not have any arguments"). It returns either () or a Result, covered below.

#[test] functions are compiled only when the crate is built in test mode, with rustc --test. Test mode does two things: it turns on the test configuration flag, so code marked #[cfg(test)] is included, and it replaces your main with libtest's runner. When you run cargo test, Cargo builds a separate test binary in this mode for each testable target (by default the library, each binary, and each file in tests/), then runs it. libtest collects every #[test] function, runs each one on its own thread (several at a time by default), and prints a summary.

A test fails if it panics, or if it returns an Err. Otherwise it passes:

#[test]
fn two_plus_two() {
    assert_eq!(2 + 2, 4);
}

assert_eq! panics when its two arguments are not equal. Here they are equal, so the function returns normally and the test passes. If it panicked, the panic would unwind out of the test's thread, libtest would catch it at that thread boundary, and the test would be reported as failed while the other tests kept running. (Cargo builds tests with unwinding panics even if a profile sets panic = "abort", because the runner depends on catching them.)

Check your understanding A failing test is a panic. What happens if a test calls std::process::exit(0) instead? Show answer

The whole test process ends at once. exit does not unwind: destructors (Drop implementations) on the stack of the current thread and of other threads do not run, catch_unwind cannot intercept it, and libtest never regains control to record a result or print its summary. Tests running on other threads, and tests that have not started yet, are never reported.

The process's exit status is whatever was passed to exit. With exit(0), cargo test sees a successful test binary and does not report a failure, even though some tests were never checked. A nonzero status makes the run fail, but still without saying which test caused it.

The exercise Build a test runner that catches panics relies on this difference. One of its operations exits the process, so the only way to handle an ignored case containing it is to not run that case at all; catch_unwind cannot recover from it.

The same reasoning applies outside tests. A caller can catch an unwinding panic, or receive an Err, and decide what to do next. When library code calls exit, the calling program gets no such choice.

The assert macros

The standard library provides three assertion macros:

assert!(cond);                  // panics if cond is false
assert_eq!(left, right);        // panics if left != right
assert_ne!(left, right);        // panics if left == right

Each accepts an optional message after the required arguments, written with the same syntax as format!:

assert!(v.len() < 10, "vector grew to {} entries", v.len());

The message is evaluated and included in the panic only if the assertion fails.

assert_eq! and assert_ne! require both operand types to implement PartialEq, which performs the comparison, and Debug, which prints both values on failure. Comparing values of your own type that lacks Debug is a compile error (E0277); adding #[derive(Debug)] to the type fixes it. Rewriting the check as assert!(a == b) also compiles, but then a failure no longer shows the two values.

A failing assert_eq!(4, 5) panics with this message:

assertion `left == right` failed
  left: 4
 right: 5

There are two spaces before left, one before right, and each value is printed with its Debug format. A custom message is appended after a colon on the first line, for example assertion `left == right` failed: ctx. The exercise macro_rules! scoping is not item scoping asks you to reproduce these strings exactly. The test runner may also print the thread name and source location, but those come from the panic hook and are not part of the message.

The standard library also provides debug_assert!, debug_assert_eq! and debug_assert_ne!. They are still type-checked in every build, but they are evaluated only when the debug_assertions setting is on. That setting is on by default in unoptimized builds such as cargo build and cargo test, and off in optimized builds such as cargo build --release or rustc -O. Use them for internal consistency checks that are too expensive to run in release builds. Do not use them to validate input your program receives, because in an optimized build the check does nothing.

#[should_panic]

#[should_panic] inverts the rule for one test: the test passes only if its body panics.

#[test]
#[should_panic(expected = "denominator")]
fn rejects_zero() {
    Ratio::new(1, 0).unwrap();
}

In this example Ratio::new is assumed to return an error whose Debug output mentions the denominator, so unwrap panics with a message that contains "denominator". The optional expected value is matched as a case-sensitive substring of the panic message. If the body panics with a message that does not contain it, the test fails and libtest prints both the actual message and the expected substring.

Check your understanding Why is #[should_panic] without expected risky? Show answer

Without expected, the test passes on any panic, including one unrelated to the behavior you meant to check.

Suppose a test builds some input data, indexes into a vector, and only then calls the function it is testing. After a refactor the input shrinks, the index goes out of bounds, and the test panics before reaching the function. The test still passes, and it will keep passing without checking anything.

Clippy's should_panic_without_expect lint (in the pedantic group, which is off by default) reports this pattern. Adding expected = "..." with a distinctive part of the intended message prevents it. The cost is that the test now depends on the message's wording and must be updated if that wording changes.

Tests that return Result

A test may return Result<(), E> instead of (), which lets it use the ? operator. E must implement Debug.

#[test]
fn parses() -> Result<(), std::num::ParseIntError> {
    let n: i32 = "42".parse()?;
    assert_eq!(n, 42);
    Ok(())
}

If the function returns Err, the test fails and the error's Debug form is printed, for example Error: ParseIntError { kind: InvalidDigit }. Two related rules:

  • #[should_panic] can only be used on a test that returns (). Combining it with a Result return type is a compile error: "functions using #[should_panic] must return ()".
  • assert!(result.is_ok()) reports only that the assertion failed, not what the error was. Returning the Result with ?, or calling unwrap(), prints the error value. Clippy's assertions_on_result_states lint (in the restriction group, also off by default) flags the is_ok/is_err pattern.

Clippy lints for assertions

Clippy lints #[cfg(test)] code only when that code is compiled, so plain cargo clippy skips it. Use cargo clippy --tests (or --all-targets) to lint tests as well. Lints in Clippy's default groups are reported unless you allow them; pedantic and restriction lints must be enabled explicitly. These are the assertion-related lints, checked against Clippy 1.98:

lint group catches
bool_assert_comparison default (style) assert_eq!(x, true); use assert!(x)
assertions_on_constants default (style) assert!(true) or assert!(false)
eq_op default (correctness, an error) assert_eq!(x, x) outside test functions; it does not fire inside #[test] functions
manual_assert_eq pedantic assert!(a == b); use assert_eq!(a, b)
should_panic_without_expect pedantic #[should_panic] with no expected
assertions_on_result_states restriction assert!(r.is_ok()) and assert!(r.is_err())
missing_assert_message restriction an assertion with no message; it skips test functions
tests_outside_test_module restriction a #[test] function outside a #[cfg(test)] module

Lint names and group membership can change between Clippy versions; the Clippy lint list for your toolchain is the authority.

Why this is an article and not an exercise

The site compiles Rust submissions as ordinary programs with rustc -O, and runs Clippy with clippy-driver -D warnings. Neither command uses test mode, so cfg(test) is off. Suppose a submission contains this:

#[cfg(test)]
mod tests {
    #[test]
    fn definitely_broken() {
        assert_eq!(1, 2);
    }
}

With cfg(test) off, the compiler removes the whole module before name resolution and type-checking. Even a type error inside it would not be reported. The test never runs, and Clippy does not lint it. Nothing in this module can affect whether the submission passes; the grading harness only checks the functions it calls.

So the site cannot grade #[test] functions. Instead, the exercise Build a test runner that catches panics asks you to implement the core of what libtest does: skip ignored cases, run each case inside catch_unwind, recover the panic message from the payload, and compare it with an expected substring the way #[should_panic(expected = ...)] does.

step 19 of 22

Modules, Visibility, Testing and Docs

Up next Build a test runner that catches panics