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
Hide 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
Hide 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 aResultreturn 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 theResultwith?, or callingunwrap(), prints the error value. Clippy'sassertions_on_result_stateslint (in therestrictiongroup, also off by default) flags theis_ok/is_errpattern.
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.