Skip to content
beginner Phase 15 · Testing, Benchmarks, and Profiling

Unit Tests and Assertions

Write #[test] functions with the standard assertion macros and should-panic checks.

35m
0 problems
Topic Progress 0%

Where a Test Lives: The #[cfg(test)] Module and Why Release Builds Never See It

A function compiles, the demo works, and then you ship the edge case nobody checked. The cheapest insurance is a test that lives next to the code it protects — cheap enough that writing it takes less time than deciding whether to write it.

Rust unit tests are ordinary functions marked with #[test], conventionally placed in a mod tests block at the bottom of the file under test:

pub fn trim_domain(email: &str) -> Option<&str> {
    email.split_once('@').map(|(_, domain)| domain)
}

#[cfg(test)]
mod tests {
    use super::*; // pull the module under test into scope

    #[test]
    fn extracts_domain() {
        assert_eq!(trim_domain("ada@example.com"), Some("example.com"));
    }
}

Two attributes do all the work. #[test] registers the function with the test harness. #[cfg(test)] compiles that module only when the test configuration is active, so cargo build and cargo build --release never see it. Test-only helpers and dev-dependency imports add zero bytes to the shipping artifact — not dead code, not unreachable code, nothing. After the low-level work in Common Unsoundness Patterns, this is the practical counterpart: you prove behavior without shipping the proof.

use super::*; is what makes a unit test special. The test module is a child of the module under test, so it can call private functions and read private fields. A file under tests/ cannot — it links your library as an external crate and sees only the public API. That distinction decides where a test goes: private behavior gets a unit test beside the code, while the public contract gets an integration test, which is exactly what Integration Tests and Test Layout handles.

What one cargo test actually runs

cargo test with no arguments executes three suites in one command: unit tests in src/, integration tests in tests/, and doc tests extracted from /// examples. Cargo builds a test harness binary per target, runs them in parallel — one thread per core by default — and prints a summary block per suite.

cargo test                       # unit + integration + doc tests, debug profile
cargo test --lib                 # only the library's unit tests
cargo test --release             # tests against optimized code
cargo test trim_domain           # substring filter on full test names
cargo test -- --nocapture        # keep println! output from passing tests
cargo test -- --test-threads=1   # run serially; panics are easier to read
cargo test -- --include-ignored  # also run #[ignore]d tests

Filtering is a plain substring match on the test's full name — tests::extracts_domain — so cargo test tests:: runs every test inside mod tests, and cargo test extracts runs just that one.

Piece What it does
#[test] register the function with the harness
#[cfg(test)] compile this module only under the test profile
use super::* import the module under test, private items included
--lib, --bins, --test <name> select a single target
-- --exact <name> match a name exactly instead of by substring

A test fails two ways: an assert! family macro panics, or the function returns Err. Tests may return a result directly — #[test] fn parses() -> Result<(), ParseError> — and the ? operator turns any error into a failure whose message the harness prints.

Two habits worth forming now. Name tests after behavior (rejects_empty_input) rather than after functions (test_trim_2), because the name is the only documentation a failure report gives you. And keep tests independent: the harness runs them concurrently across threads, so shared global state produces flaky failures that vanish under --test-threads=1 — the classic sign of a test that was never really independent.

Interviewers often open with "where do tests live, and what does #[cfg(test)] buy you?" Answer with the zero-bytes-in-release point and you have said something precise instead of something generic.

Reading an Assertion Failure: assert, assert_eq, assert_ne, and the Message That Debugs For You

An assertion's job is not merely to stop the test — it is to explain, in one screen, what you believed and what was actually true.

Rust gives you three primitives, with formatting built in:

assert!(is_valid("ada@example.com"));     // a boolean claim
assert_eq!(total, 1200);                  // left == right
assert_ne!(status, Status::Unset);        // left must differ from right

assert_eq! and assert_ne! are not symmetric wrappers around ==: they require both sides to implement Debug, because the whole point is printing the values when they disagree. Compare a type without Debug and you get a compile error naming the bound — the compiler is telling you to make the type printable before you can compare it in a test. A failing comparison then prints:

assertion `left == right` failed:
  left: "Ada Lovelace"
 right: "Ada"

You learn the actual value without writing a single println!.

Every macro also accepts a custom message, and that is where context the values cannot carry goes. Arguments after the values are a format! template:

assert_eq!(
    trimmed, "example.com",
    "split_once returned the wrong half for input {input:?}"
);
Macro Use it when Failure prints
assert!(expr) truth is all you can claim the expression, plus your message
assert!(expr, msg, …) the claim needs surrounding context formatted message
assert_eq!(a, b) equality is the contract both values via Debug
assert_ne!(a, b) uniqueness or a non-default is required both values via Debug

Returning an error instead of panicking

A test may also return a result:

#[test]
fn parses_config() -> Result<(), ParseError> {
    let cfg = parse("timeout=5")?;
    assert_eq!(cfg.timeout, 5);
    Ok(())
}

When ? propagates an Err, the harness reports the error's Debug output as the failure. Use this for fixture work — loading a file, opening a temporary database — so setup problems read as real errors instead of called Option::unwrap() on a None value with a line number in test machinery.

Seeing the output you printed

Test stdout is captured; passing tests show nothing. Release it with cargo test -- --nocapture, or --show-output to print output from passing tests only:

cargo test parses -- --nocapture

A detail interviewers like to probe: assert_eq! uses == semantics for the type you gave it, so assert_eq!(0.1 + 0.2, 0.3) fails. Float comparisons need an epsilon check — (a - b).abs() < 1e-9 — or an approx helper. Knowing that the equality assertion is exact for its type saves an afternoon of wondering why a "trivially true" test keeps failing.

The same macros exist outside tests as debug_assert!, debug_assert_eq!, and debug_assert_ne! — identical behavior in debug builds, compiled out entirely in release. Use them for internal invariants (an index that must be in range) that are cheap to check while developing and too hot to check per request in production, and reserve real assert! for conditions whose violation must never be tolerated, test or not.

Also worth knowing for debugging failures: the harness prints the panic location, and RUST_BACKTRACE=1 cargo test gives you the full stack through your code. When an assertion deep in a helper fails, that backtrace identifies which caller constructed the bad value — often more useful than the values themselves.

Finally, assert! macros live in the prelude, but test-oriented extras do not: pretty_assertions (from a dev-dependency) gives colored, side-by-side diffs for long strings, and insta snapshots entire serialized structures. Both matter once your equality assertions start comparing multi-line output — at that point the failure report, not the comparison, is what you are really buying.

Proving the Panic: #[should_panic(expected = "...")] and Testing the Failure Paths

Half of a robust API is its refusal path: parse rejects garbage, withdraw rejects overdrafts, split_at refuses an out-of-range index. A panic you never test is a panic that can change silently on the next refactor.

pub fn withdraw(balance: &mut u64, amount: u64) {
    assert!(
        amount <= balance,
        "insufficient funds: need {amount}, have {balance}"
    );
    *balance -= amount;
}

#[test]
#[should_panic(expected = "insufficient funds")]
fn rejects_overdraft() {
    let mut balance = 10;
    withdraw(&mut balance, 50);
}

#[should_panic] flips the verdict: a panicking test passes, and a test that returns normally fails with test did not panic as expected. The expected string is a substring match against the panic message — not a regex, not an exact comparison — so expected = "insufficient funds" matches the full sentence above. Leave expected off and any panic counts, which is almost always too loose: the test then passes even when the code panicked for the wrong reason, such as an index out of bounds somewhere in the arithmetic.

Form Verdict
#[should_panic] any panic passes
#[should_panic(expected = "…")] message must contain that substring
no attribute, no Result a panic fails the test
#[test] fn … -> Result<…> a returned Err fails the test

Three ways should_panic lies to you

  1. Wrong-panic false positive. Without expected, a bug that panics earlier still passes. Pin the message for anything but a smoke test.
  2. Substring too broad. expected = "invalid" also matches an unrelated message about an invalid field. Match the distinctive phrase: expected = "insufficient funds".
  3. The harness panicked, not your code. An unwrap() on a None fixture built outside the test body satisfies a loose should_panic. Construct inputs inside the test.

For library code the stronger move is to not panic at all. Return a Result and assert on the value:

#[test]
fn rejects_overdraft_as_result() {
    let err = try_withdraw(10, 50).unwrap_err();
    assert_eq!(err, BankError::Insufficient { have: 10, need: 50 });
}

unwrap_err() panics with the success value if the call unexpectedly succeeded, and the following assertion prints both sides. This checks the exact error variant — considerably stronger than a message substring, and it survives message rewording.

What a single cargo test runs for you

One cargo test executes unit tests (#[test] inside src/), integration tests (each file directly under tests/), and doc tests extracted from /// examples:

/// Withdraw funds, panicking on overdraft.
///
/// ```
/// # use mylib::withdraw;
/// let mut b = 100;
/// withdraw(&mut b, 40);
/// assert_eq!(b, 60);
/// ```
pub fn withdraw(balance: &mut u64, amount: u64) { /* … */ }

Every fenced Rust block in a doc comment is compiled and run as its own test, so a broken example fails CI instead of misleading readers. That is also why #[should_panic] works in doc tests when you annotate the block: ```should_panic.

Tag slow or environment-dependent tests #[ignore]; run them deliberately with cargo test -- --include-ignored. And when you need to assert a panic without string matching at all, std::panic::catch_unwind returns the payload — useful when the panic message is unstable but the fact of the panic is the contract:

let mut balance = 10;
let payload = std::panic::catch_unwind(|| withdraw(&mut balance, 50));
let msg = payload.downcast_ref::<String>().unwrap();
assert!(msg.contains("insufficient funds"));

catch_unwind hands you the boxed panic payload — a &str or String message — so you can assert on it precisely, including messages built dynamically where a static expected = "…" would be a guess. Note the cost: it aborts nothing and unwinds normally, but it also cannot catch panics in extern "C" functions or panic = "abort" builds, where the process dies and only the harness output survives.

Where these tests should live once they need the public API is the next question: Integration Tests and Test Layout.

Quiz

1. You add a #[test] helper function inside a mod tests block in src/lib.rs. What happens when you run cargo build --release?

Question 1 options

2. assert_eq!(a, b) fails to compile with 'User' doesn't implement 'Debug'. What does the compiler actually require, and why?

Question 2 options

3. A test must verify that withdraw panics with the message 'insufficient funds: need 50, have 10'. Which form is correct?

Question 3 options

4. You run cargo test with no arguments in a crate that has unit tests, a tests/ directory, and /// examples with runnable code. Which suites execute?

Question 4 options

Flashcards

Question

What two attributes structure a Rust unit test, and what does each one buy you?

Answer

#[test] registers the function with the test harness; #[cfg(test)] on the enclosing mod tests compiles that module only in test builds, so helpers and dev-only code add zero bytes to release artifacts. The conventional block also opens with use super::*, which imports the module under test — private items included — because a test module is a child of the code it tests.

Question

Why can a unit test call a private function while a file under tests/ cannot?

Answer

A unit test lives in a child module of the module under test, and child modules may access their parent's private items. A file under tests/ is compiled as a separate crate that links your library as a dependency, so it sees only the public API exported from lib.rs — exactly what a downstream user sees.

Question

What bound do assert_eq! and assert_ne! require, and what fails without it?

Answer

Both sides must implement Debug, because the macro prints left and right with {:?} when the assertion fails. The comparison itself only needs PartialEq, so a type with PartialEq but no Debug compiles under == yet errors under assert_eq! — implement Debug (derive it) to get readable failure output.

Question

How does #[should_panic(expected = "...")] match the panic message?

Answer

It is a plain substring match: the test passes if the panic message contains the given string anywhere. It is neither a regex nor an exact match. Omitting expected lets any panic pass, which can mask a panic from the wrong code path; too short a substring accepts unrelated messages that happen to contain it.

Question

What does a bare cargo test run, and which flags narrow it?

Answer

It runs unit tests (src/), integration tests (tests/*.rs), and doc tests from /// examples, each target built and executed in parallel. Narrowing: --lib for unit tests only, --test <name> for one integration file, --doc for doc examples, a substring argument to filter test names, plus -- --include-ignored, -- --nocapture, and -- --test-threads=1 for control over execution.

Revision Notes

Key Takeaways

  • 1. #[cfg(test)] gates the entire tests module, so test code contributes zero bytes to release artifacts — that is the precise answer to 'what does cfg(test) buy you?'
  • 2. use super::* gives a unit test access to private functions and fields; a tests/ file links the library as an external crate and sees only the public API
  • 3. assert_eq!/assert_ne! require Debug (not Display) because the failure report prints both operands with {:?} — that report is the real output you are buying
  • 4. #[should_panic(expected = "…")] matches a substring of the panic message; without it any panic passes, so wrong-panic bugs slip through
  • 5. cargo test runs three suites in one command — unit, integration, and doc tests — and name filters are substring matches on the full test path

Interview Tips

  • • "What does #[cfg(test)] do?" — say it removes the module from non-test compilations, so test helpers and dev-dependencies never ship; follow with use super::* to show you know why tests can reach privates
  • • "How do you test that a function panics?" — should_panic with an expected substring, then immediately note its limits and show the stronger Result-based assertion or catch_unwind payload check
  • • "Your test fails only with --test-threads=1 — what's wrong?" — shared state between concurrently running tests; name the fix (per-test fixtures) and mention that flakiness under parallelism is a design bug, not a harness setting
  • • "assertEquals vs assert in Rust?" — the Debug bound and the left/right failure output; mention floats need an epsilon comparison because == is exact for the type

Cheat Sheet

Unit Tests and Assertions

Canonical unit test skeleton:

#[cfg(test)]
mod tests {
    use super::*;            // child module: sees private items

    #[test]
    fn rejects_empty_input() {
        assert_eq!(parse(""), Err(ParseError::Empty));
    }
}

Choosing the assertion:

Situation Use Notes
Boolean claim assert!(cond) add msg, {args} for context
Equality with readable failure assert_eq!(a, b) both sides need Debug
Uniqueness / non-default assert_ne!(a, b) both sides need Debug
Test returns Result ? + Ok(()) Err becomes a failure with its Debug output
Behavior is a panic #[should_panic(expected = "…")] substring match on the message
Panic message is dynamic catch_unwind + payload assert fails if panic = "abort"

Running:

cargo test                    # unit + integration + doc tests
cargo test --lib name_filter  # unit tests, name substring
cargo test -- --nocapture     # show test println! output
cargo test -- --test-threads=1  # serial, for debugging
cargo test -- --include-ignored

Rules of thumb: tests named for behavior (rejects_empty_input); private behavior tested beside the code; public behavior tested from tests/; no shared mutable state between tests (the harness runs them concurrently).

Continue Learning