Skip to content

06 · Testing in Rust

Rust has a testing framework built directly into the language and Cargo — no external test runner to install, no separate config file to wire up. Tests live alongside the code they exercise (or in a dedicated tests/ folder for higher-level checks), and cargo test finds and runs all of them. This module covers writing unit tests, structuring integration tests, and the assertion tools you'll use constantly from here on.

Your first test

fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;   // bring the outer module's items (like `add`) into scope

    #[test]
    fn adds_two_numbers() {
        assert_eq!(add(2, 3), 5);
    }
}
cargo test
running 1 test
test tests::adds_two_numbers ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

The pieces: #[cfg(test)] tells the compiler to only compile the tests module when running tests (it's entirely absent from a normal cargo build or cargo run), mod tests is just a regular module by convention, and #[test] marks an individual function as a test case cargo test should run and check for a panic.

Assertion macros

fn is_even(n: i32) -> bool {
    n % 2 == 0
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn checks_equality() {
        assert_eq!(2 + 2, 4);              // fails if not equal
        assert_ne!(2 + 2, 5);              // fails if equal
    }

    #[test]
    fn checks_boolean_conditions() {
        assert!(is_even(4));               // fails if false
        assert!(!is_even(3));              // fails if true
    }

    #[test]
    fn assert_with_custom_message() {
        let result = 2 + 2;
        // The message only prints if the assertion fails -- it's not
        // shown on success, so make it explain *what* went wrong.
        assert_eq!(result, 4, "expected 2 + 2 to equal 4, got {result}");
    }
}

assert_eq!/assert_ne! print both the left and right values on failure (they require Debug on the compared type), which is why they're preferred over a plain assert!(a == b) — a failing assert! only tells you the condition was false, not what the actual values were.

Testing that code panics

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("division by zero");
    }
    a / b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "division by zero")]
    fn divide_by_zero_panics() {
        divide(10, 0);
    }
}

#[should_panic] inverts the usual pass condition — the test passes only if the function panics. The optional expected = "..." checks that the panic message contains that substring, which guards against the test accidentally passing because of a different, unrelated panic somewhere else in the function.

Testing Result-returning functions

Test functions can themselves return Result<(), E> instead of panicking on failure — this lets you use ? inside a test, which is often cleaner than .unwrap() scattered everywhere:

use std::num::ParseIntError;

fn parse_and_double(input: &str) -> Result<i32, ParseIntError> {
    let n = input.parse::<i32>()?;
    Ok(n * 2)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn doubles_a_valid_number() -> Result<(), ParseIntError> {
        let result = parse_and_double("21")?;
        assert_eq!(result, 42);
        Ok(())
    }
}

A test returning Err counts as a failed test, printed with Debug — handy for chaining several fallible setup steps with ? instead of .unwrap().

Organizing tests: unit vs integration

Unit tests Integration tests
Location #[cfg(test)] mod tests inside the same file as the code Separate files under tests/ at the project root
Can see private items? Yes (use super::*; has access) No — only the crate's public API
Purpose Verify one function/module in isolation Verify the crate works as a whole, as an outside caller would use it
my_project/
    Cargo.toml
    src/
        lib.rs          -- contains #[cfg(test)] unit tests
    tests/
        integration.rs  -- a separate crate that only sees `pub` items
// tests/integration.rs
use my_project::add;   // only works if `add` is `pub` in src/lib.rs

#[test]
fn add_works_from_outside() {
    assert_eq!(add(2, 2), 4);
}

Each file directly under tests/ is compiled as its own separate crate that depends on your library — that's why it can only see pub items, and also why splitting integration tests into multiple files is fine (they each run independently, in parallel, by default).

Running specific tests and controlling output

cargo test                    # run every test
cargo test adds_two_numbers   # run only tests whose name contains this string
cargo test -- --nocapture     # show println! output even for passing tests
cargo test -- --test-threads=1   # run tests sequentially (default is parallel)

Tests run in parallel by default and each gets its own thread — this is why tests that touch shared state (like the same file on disk) can flake unless you either give each test its own resource (e.g. a unique temp file name) or force sequential execution.

#[ignore] for slow tests

#[test]
#[ignore]   // skipped by default, e.g. a slow test hitting a real network call
fn expensive_integration_check() {
    // ...
}
cargo test              # skips ignored tests
cargo test -- --ignored # runs ONLY the ignored tests

How It Actually Works

#[test] is a procedural attribute macro: at compile time, cargo test compiles your crate in a special mode where every #[test]-annotated function is registered into a generated main for a separate test harness binary — the harness itself is what spawns a thread per test, catches panics via catch_unwind to determine pass/fail, and collects results, rather than any special runtime feature of Rust itself. A test "failing" is really just: the test function panicked, the harness caught that unwind at the thread boundary, and reported it as a failure instead of letting it crash the whole test binary — the same stack-unwinding mechanism from Module 5's panic discussion, repurposed as the pass/fail signal.

Running tests on separate threads by default is why shared mutable state (a shared temp file, a global counter, an env var) causes flaky tests: two test threads racing on the same file descriptor have no ordering guarantee, and Rust's compile-time data-race prevention only protects memory within one process's type system — it has no way to know two independent test functions are implicitly sharing an external resource like a file path or port number. cargo test -- --test-threads=1 forces sequential execution by telling the harness binary (via its own CLI parsing after the --) to use a single worker thread, trading speed for determinism when tests can't be made independent.

Cheat sheet

Macro/attribute Purpose
#[test] Marks a function as a test case
#[cfg(test)] Compiles a module only when testing
assert!(cond) Fails if cond is false
assert_eq!(a, b) / assert_ne!(a, b) Fails if not equal / equal, prints both values
#[should_panic] Test passes only if the function panics
#[ignore] Skips the test unless --ignored is passed
cargo test <name> Run tests whose name contains <name>
cargo test -- --nocapture Show println! output from passing tests

Exercise

Write a function fn is_palindrome(s: &str) -> bool (case-insensitive, ignoring spaces) in a file. Add a #[cfg(test)] mod tests module with at least four #[test] functions: one for a simple palindrome ("level"), one for a phrase with spaces and mixed case ("A Man A Plan A Canal Panama"), one for a non-palindrome, and one for an empty string (decide and assert what the correct behavior should be). Run cargo test -- --nocapture and confirm all four pass.