Testing with #[test]
Rust has a test framework built directly into the language and toolchain: mark any function with #[test], run cargo test, and Cargo finds, compiles, and runs every one of those functions for you. There is no separate testing library to install and no configuration file to write. Because tests usually live right next to the code they check, writing a test the moment you write a function is nearly frictionless, which is a big reason Rust code tends to be well-tested. This lesson covers unit tests written with #[test]: how the test harness works, the assertion macros, #[should_panic], tests that return Result, and the mistakes beginners run into most often.
Overview: How Testing Works in Rust
The #[test] attribute is a built-in attribute that rustc understands specially. When you run cargo test, Cargo compiles your crate a second time with testing enabled — the equivalent of passing --test to rustc. This does two things. First, it turns on the test configuration flag, so any code guarded by #[cfg(test)] is included in this build (and excluded from a normal cargo build). Second, it generates a special entry point, the test harness, that discovers every function tagged #[test] and calls each one in turn, on its own thread, recording whether it panicked.
A test passes if the function returns normally, and fails if it panics — whether the panic comes from an assert! macro, an explicit panic!, an .unwrap() on None/Err, or an out-of-bounds index. There is no separate pass/fail return value; panicking is the only failure signal (with one exception covered below: tests that return Result).
The overwhelmingly common convention is to keep unit tests in the same file as the code, inside a nested module named tests and annotated #[cfg(test)]:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
The #[cfg(test)] attribute means this entire module is compiled out of ordinary builds and only exists when testing — your shipped binary never contains test code. Inside the module, use super::*; pulls every item from the parent (the rest of the file) into scope, which is why you can call functions defined above the module without repeating their path.
Rust actually has three kinds of tests. Unit tests (the subject of this lesson) live inside src/ files in #[cfg(test)] modules and can see private items, since they’re part of the same crate. Integration tests live in a top-level tests/ directory, are compiled as separate crates, and can only exercise your crate’s public API — useful for testing the library the way an external user would. Doc tests are code blocks inside /// documentation comments; cargo test compiles and runs them too, which keeps your examples from silently rotting out of date. Unit tests with #[test] are what you’ll write the vast majority of the time, so that’s the focus here.
Syntax
The core pieces you’ll use when writing tests:
| Item | Purpose |
|---|---|
#[test] |
Marks a function as a test case; the function must take no arguments |
#[cfg(test)] |
Compiles the annotated item only when building for tests |
#[should_panic] |
The test passes only if the function panics; fails if it doesn’t |
#[should_panic(expected = "text")] |
Also checks that the panic message contains text |
#[ignore] |
Skips the test by default; run it with cargo test -- --ignored |
assert!(expr) |
Panics if expr is false |
assert_eq!(a, b) |
Panics if a != b, printing both values |
assert_ne!(a, b) |
Panics if a == b |
Put together, a typical test module looks like this:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
// arrange
let input = 2;
// act
let result = some_function(input);
// assert
assert_eq!(result, 4);
}
#[test]
#[should_panic]
fn test_name_panics() {
// code that is expected to panic
}
#[test]
#[ignore]
fn slow_test() {
// skipped unless `cargo test -- --ignored`
}
}
Examples
Example 1: A basic test with assert_eq!
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let sum = add(2, 3);
println!("2 + 3 = {}", sum);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn adds_negative_numbers() {
assert_eq!(add(-2, -3), -5);
}
}
Output (running with cargo run):
2 + 3 = 5
Running cargo test on the same file ignores fn main entirely and instead runs the two #[test] functions:
running 2 tests
test tests::adds_negative_numbers ... ok
test tests::adds_two_numbers ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
add is a plain function with no knowledge that it’s being tested; the tests call it like any other caller and use assert_eq! to check the result. Note that cargo run and cargo test compile the same source in two different modes — one builds your ordinary main, the other builds the test harness.
Example 2: A test that returns Result and uses ?
fn divide(a: i32, b: i32) -> Result {
if b == 0 {
Err(String::from("division by zero"))
} else {
Ok(a / b)
}
}
fn main() {
match divide(10, 2) {
Ok(value) => println!("10 / 2 = {}", value),
Err(e) => println!("Error: {}", e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn divides_evenly() {
assert_eq!(divide(10, 2), Ok(5));
}
#[test]
fn returns_error_on_zero() {
assert_eq!(divide(10, 0), Err(String::from("division by zero")));
}
#[test]
fn works_with_question_mark() -> Result<(), String> {
let value = divide(20, 4)?;
assert_eq!(value, 5);
Ok(())
}
}
Output (cargo run):
10 / 2 = 5
The third test, works_with_question_mark, returns Result<(), String> instead of (). This is a special case the test harness understands: if the function returns Ok(()), the test passes; if it returns Err, the test fails and the harness prints the error value (this works because String implements Debug, which the error type of a test’s Result must do). This lets you use the ? operator inside a test to propagate failures from functions that themselves return Result, instead of reaching for .unwrap() everywhere.
Example 3: should_panic for expected failures
fn get_element(items: &[i32], index: usize) -> i32 {
items[index]
}
fn main() {
let numbers = vec![10, 20, 30];
let value = get_element(&numbers, 1);
println!("Element at index 1: {}", value);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gets_valid_element() {
let numbers = vec![10, 20, 30];
assert_eq!(get_element(&numbers, 2), 30);
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn panics_on_out_of_bounds() {
let numbers = vec![10, 20, 30];
get_element(&numbers, 5);
}
}
Output (cargo run):
Element at index 1: 20
cargo test output:
running 2 tests
test tests::gets_valid_element ... ok
test tests::panics_on_out_of_bounds ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Indexing a slice out of bounds panics at runtime with a message like index out of bounds: the len is 3 but the index is 5. Normally that panic would fail a test, but #[should_panic(expected = "index out of bounds")] flips the expectation: the test only passes because it panicked, and specifically because the panic message contains the given text. Always prefer the expected form over bare #[should_panic] — without it, a test would still pass even if the function panicked for a completely unrelated reason.
How It Works Step by Step
When you type cargo test, here is what actually happens:
- Cargo recompiles your crate with the
testcfg flag active, which pulls every#[cfg(test)]item into the build. - The compiler generates a hidden test-runner
mainfunction that replaces your ownfn mainas the binary’s entry point for this build only. - The runner collects every
#[test]-tagged function as an independent test case and, by default, runs them concurrently across a thread pool (this is why two tests that both write to the same file on disk can flake against each other). - Each test’s
stdoutis captured; if the test passes, that output is thrown away and hidden, which is whyprintln!inside a passing test appears to do nothing. Failing tests have their captured output printed alongside the failure so you can debug it. Passingcargo test -- --nocaptureshows output for every test. - A test’s outcome is decided by whether it panicked: no panic and no
Errreturn isok; a panic (or anErrfrom aResult-returning test) isFAILED— unless#[should_panic]is present, in which case the logic is inverted. - Once every test finishes, the runner prints a summary line (
test result: ok. N passed; ...) and exits with a non-zero status if anything failed, which is what makescargo testuseful in CI.
Common Mistakes
Mistake 1: Forgetting `use super::*;`
Functions defined outside the tests module aren’t automatically visible inside it — modules don’t inherit their parent’s names for free, only their ability to reach upward with super::. Without the import, calling add from inside the module fails to resolve:
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}
Compiling this under cargo test fails with error[E0425]: cannot find function "add" in this scope, because tests is a genuinely separate module and add was never brought into it. Add use super::*; at the top of the module to fix it:
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
println!("{}", add(2, 3));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}
Output:
5
Mistake 2: Using a moved value inside a test
Tests are ordinary Rust functions, so ownership rules apply exactly as everywhere else. A very common beginner mistake is passing an owned, non-Copy value like String into a function and then trying to use it again for an assertion:
fn consume(s: String) {
println!("Consumed: {}", s);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn move_mistake() {
let name = String::from("Ferris");
consume(name);
assert_eq!(name, "Ferris");
}
}
Because consume takes String by value, calling consume(name) moves name into the function; the binding name in the test is no longer valid afterward, so the compiler rejects the following assert_eq! with a “borrow of moved value” error — it isn’t a testing bug, it’s the same move rule you’d hit anywhere else. Clone the value before consuming it, or change consume to borrow instead:
fn consume(s: String) {
println!("Consumed: {}", s);
}
fn main() {
consume(String::from("Ferris"));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn does_not_move_original() {
let name = String::from("Ferris");
consume(name.clone());
assert_eq!(name, "Ferris");
}
}
Output:
Consumed: Ferris
Best Practices
- Keep unit tests next to the code they exercise in a
#[cfg(test)] mod testsblock; reserve thetests/directory for integration tests that only touch your crate’s public API. - Give tests descriptive names — the name is what shows up in the pass/fail output, so
returns_error_on_zerois far more useful thantest1. - Prefer
assert_eq!/assert_ne!over a bareassert!(a == b); on failure they print both values, which saves a debugging round-trip. - Use
#[should_panic(expected = "...")]instead of a bare#[should_panic]so the test can’t accidentally pass because of an unrelated panic. - Test one behavior per function; several small, focused tests localize failures better than one large test that checks many things.
- Don’t share mutable global state (files, environment variables, static counters) between tests without synchronization — tests run in parallel by default and will race.
- Reach for a
Result-returning test with?when a test needs to call several fallible functions, instead of chaining.unwrap()calls. - Mark slow or expensive tests
#[ignore]and run them deliberately in CI withcargo test -- --ignored, so the everyday test suite stays fast.
Practice Exercises
- Write a function
is_even(n: i32) -> booland a#[cfg(test)] mod testswith at least two tests: one asserting a known even number returnstrue, one asserting a known odd number returnsfalse. - Write a function
first_char(s: &str) -> Option<char>that returnsNonefor an empty string. Write tests covering both theSomeandNonecases, and a third test using#[should_panic]around a deliberate.unwrap()on theNonecase. - Write a function
safe_divide(a: i32, b: i32) -> Result<i32, String>and a test function that returnsResult<(), String>and uses?to call it, asserting the successful result equals the expected quotient.
Summary
#[test]marks a function as a test case;cargo testcompiles a special test harness that finds and runs every one, on separate threads, by default.- A test fails if it panics;
assert!,assert_eq!, andassert_ne!are the usual ways to trigger that panic on a failed check. - Wrap tests in
#[cfg(test)] mod tests { use super::*; ... }so test code never ships in your production build, and so you can call the parent module’s items. #[should_panic(expected = "...")]flips the pass condition to “must panic with this message”; always includeexpectedto avoid false positives.- A test function may return
Result<(), E>(withE: Debug) to use the?operator instead of unwrapping every fallible call. - Tests are ordinary functions and obey the same ownership and borrowing rules as any other code — a moved value can’t be used again inside a test, either.
#[ignore]skips a test by default; unit tests (src/) can see private items, integration tests (tests/) only see the public API.
