Testing in Rust
#[test], assert! macros, a complete test module, and unit tests vs integration tests.
Testing is built into Cargo, not bolted on
Unlike many languages where testing means picking and installing a separate framework, Rust's test runner ships as part of the standard toolchain — cargo test works in any project with no extra dependency required for basic unit and integration tests. Tests are ordinary Rust functions, marked with the #[test] attribute, that Cargo compiles into a special test binary and runs automatically, reporting pass/fail for each one.
The #[test] attribute and assert! macros
A test function takes no arguments, returns nothing (or a Result, covered below), and is considered passing if it runs to completion without panicking:
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[test]
fn it_adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
Three assertion macros cover most needs: assert!(condition) panics (failing the test) if condition is false; assert_eq!(left, right) panics if the two values aren't equal, printing both values in the failure message; assert_ne!(left, right) is the inverse. All three accept an optional custom message as extra arguments, formatted like println!:
#[test]
fn checks_are_descriptive() {
let result = add(2, 2);
assert_eq!(result, 4, "add(2, 2) should equal 4, got {result}");
}
A complete test module
Idiomatic Rust keeps tests in the same file as the code they test, inside a mod tests block annotated #[cfg(test)] — meaning this module is compiled only when running tests, and is entirely absent from the release binary:
// src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("division by zero");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*; // brings the parent module's public AND private items into scope
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn add_is_commutative() {
assert_eq!(add(2, 3), add(3, 2));
}
#[test]
#[should_panic(expected = "division by zero")]
fn divide_by_zero_panics() {
divide(10, 0);
}
#[test]
#[ignore] // skipped by default — run explicitly with `cargo test -- --ignored`
fn an_expensive_check() {
// a slow test you don't want run on every ordinary `cargo test`
}
}
cargo test
running 4 tests
test tests::adds_two_numbers ... ok
test tests::add_is_commutative ... ok
test tests::divide_by_zero_panics ... ok
test tests::an_expensive_check ... ignored
test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
#[should_panic(expected = "...")] passes only if the function panics and the panic message contains the given substring — bare #[should_panic] with no expected passes on any panic at all, which can silently mask the test panicking for the wrong reason.
use super::*; is what lets the test module reach add and divide — since the tests live in a child module, they'd otherwise need the fully-qualified super::add(...) path for every call.
Tests that return a Result
A test function can also return Result<(), E> instead of panicking, letting you use ? inside a test exactly like ordinary code — useful when the code under test is itself fallible:
#[test]
fn parses_a_number() -> Result<(), std::num::ParseIntError> {
let n: i32 = "42".parse()?;
assert_eq!(n, 42);
Ok(())
}
The test fails if it returns Err, and passes on Ok(()) — this avoids a chain of .unwrap() calls just to satisfy the compiler in test code that's fundamentally about fallible operations.
Unit tests vs. integration tests
Rust distinguishes two kinds of tests by where they live, not by any special syntax:
| Unit tests | Integration tests | |
|---|---|---|
| Location | Inside src/, in a #[cfg(test)] mod tests alongside the code |
Separate files directly under a top-level tests/ directory |
| Visibility | Can see and test private (non-pub) items in the same module |
Only sees the crate's public API, via use my_crate::... |
| Compilation | Compiled only when running tests (#[cfg(test)]) |
Each file compiles as its own separate crate, linked against your library |
| Purpose | Verify one function or module in isolation | Verify the crate behaves correctly the way an external user would actually call it |
// tests/integration_test.rs — a real, separate file, not inside src/
use my_crate::add;
#[test]
fn public_api_add_works() {
assert_eq!(add(2, 2), 4);
}
cargo test runs both kinds together by default; a real project typically has many unit tests close to the code they verify, plus a smaller number of integration tests exercising the crate's public surface the way a downstream consumer actually would.
Common mistakes
- Forgetting
use super::*;inside amod testsblock and getting a confusing "cannot find function" error for a function that's clearly right there in the parent module. - Relying on
#[should_panic]with noexpectedstring, which passes even if the function panics for a completely unrelated reason than the one the test was meant to check. - Writing tests that share mutable global state (a shared temp file, a
static mut, an external resource) — Cargo runs tests in parallel on separate threads by default, and shared mutable state between them causes flaky, order-dependent failures. Isolate each test's state instead of reaching forcargo test -- --test-threads=1as a permanent workaround. - Putting integration-style tests that only need the public API inside
src/'s unit test modules — genuinely external-facing behavior belongs intests/, where it's forced to go through the same public interface a real caller would use.
Interview questions
Q: How does Cargo distinguish a unit test from an integration test?
Purely by location. Code inside src/, typically in a #[cfg(test)] mod tests block, is a unit test and can access private items in the same module. Code in a separate file under a top-level tests/ directory is compiled as its own independent crate and can only reach the library's public API — exactly what an external consumer of the crate would be limited to.
Q: Why do Rust's tests run in parallel by default, and what problem can that cause? Running each test on its own thread makes a large test suite finish much faster than running everything sequentially. The risk is shared mutable state — two tests that both read and write the same global variable, file, or external resource can interfere with each other unpredictably, producing tests that pass or fail depending on timing rather than actual correctness. The fix is to keep each test's data independent, not to disable parallelism.
Q: What exactly does #[should_panic(expected = "...")] verify, versus a bare #[should_panic]?
A bare #[should_panic] passes if the function panics for any reason at all. Adding expected = "some substring" additionally requires the panic message to contain that substring, which catches a test that happens to pass only because the code panicked somewhere else entirely, for an unrelated reason, before ever reaching the behavior actually being tested.