Writing Tests in Rust
Testing is a first-class citizen in Rust. The language and its toolchain ship with
everything you need to write unit tests, integration tests, and documentation tests —
no external framework required. The cargo test command discovers and runs them all,
prints a clear pass/fail report, and integrates naturally with CI pipelines.
The #[test] Attribute
Mark any function with #[test] to tell the compiler it is a test. Test functions
take no arguments and return either () or Result<(), E>. Cargo compiles them only
when running tests — they never ship in your release binary.
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}running 1 test test test_add ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured
Running Tests with cargo test
cargo test builds a special test binary, runs every function marked #[test],
and prints a summary. By default, tests run in parallel across multiple threads.
cargo test # run all tests cargo test test_add # run tests whose name contains "test_add" cargo test math:: # run tests inside the math module cargo test -- --test-threads=1 # force serial execution
cargo test add runs every test whose name contains the string "add".assert!, assert_eq!, and assert_ne!
Rust provides three assertion macros for tests. All panic (and therefore fail the test) when the condition is not met, printing a helpful diagnostic automatically.
Macro | Passes when | Shows on failure |
|---|---|---|
assert!(expr) | expr is true | The expression that was false |
assert_eq!(a, b) | a == b | Both values side by side |
assert_ne!(a, b) | a != b | Both values side by side |
#[test]
fn assertion_examples() {
let x = 4;
assert!(x > 0);
assert_eq!(x, 4);
assert_ne!(x, 99);
// Custom failure message (format-string style)
assert!(x < 10, "expected x < 10, got x = {}", x);
assert_eq!(x, 4, "x should still be 4, got {}", x);
}assert_eq! and assert_ne!must implement the PartialEq and Debug traits. Derive both on your own types with #[derive(Debug, PartialEq)].Testing for Panics with #[should_panic]
Some functions are supposed to panic under invalid input. Add #[should_panic] to
verify that the panic actually occurs. Use the expected parameter to check that the
panic message contains a specific string — this prevents the test from silently passing
when a different panic fires.
fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("division by zero");
}
a / b
}
#[test]
#[should_panic(expected = "division by zero")]
fn test_divide_by_zero() {
divide(10, 0);
}
#[test]
#[should_panic] // any panic passes — less precise
fn test_divide_by_zero_loose() {
divide(5, 0);
}#[should_panic] without expected in serious test suites. Without it, an unrelated panic caused by a bug elsewhere will still make the test pass.Returning Result from Tests
Tests may return Result<(), E> instead of panicking. This lets you use the ?
operator inside tests, which makes working with fallible operations (file I/O, parsing,
network calls) much more ergonomic.
use std::num::ParseIntError;
fn parse_and_double(s: &str) -> Result<i32, ParseIntError> {
let n: i32 = s.parse()?;
Ok(n * 2)
}
#[test]
fn test_parse_and_double() -> Result<(), ParseIntError> {
let result = parse_and_double("21")?;
assert_eq!(result, 42);
Ok(())
}Result-returning test returns Err, Cargo marks it as failed and prints the error value — no custom panic message needed.Unit Tests: #[cfg(test)] mod tests
Unit tests live right next to the code they test, inside a dedicated module annotated
with #[cfg(test)]. Cargo compiles this module only during cargo test, so it adds
zero overhead to your release binary.
The key advantage: because the test module is a child of the module under test, it can access private functions and types directly.
// src/math.rs
pub fn square(n: i32) -> i32 {
n * n
}
fn cube(n: i32) -> i32 { // private helper
n * n * n
}
#[cfg(test)]
mod tests {
use super::*; // brings everything, including private items, into scope
#[test]
fn test_square() {
assert_eq!(square(4), 16);
}
#[test]
fn test_cube() { // can test the private function
assert_eq!(cube(3), 27);
}
}Integration Tests: the tests/ Directory
Integration tests live in a top-level tests/ directory. Each file in that directory
is compiled as its own separate crate that links against your library — so they can
only call public API, exactly as an external user would.
my_project/
├── src/
│ └── lib.rs
└── tests/
├── integration_test.rs # one test crate
└── another_test.rs # another test crate// tests/integration_test.rs
use my_project::square; // only public API is accessible here
#[test]
fn test_square_public() {
assert_eq!(square(5), 25);
}tests/common/mod.rs(not tests/common.rs). Using a subdirectory prevents Cargo from treating the helper file as its own test crate.Doc Tests
Any code block inside a /// doc comment is compiled and run as a test by
cargo test. Doc tests ensure your examples stay correct as the code evolves —
broken examples fail the test suite immediately.
/// Adds two numbers together.
///
/// # Examples
///
/// ```
/// let result = my_crate::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}running 1 test test src/lib.rs - add (line 5) ... ok
# (hash + space) inside a doc-test code block to hide it from rendered documentation while still running it. This is useful for setup boilerplate like use statements.Controlling Test Output
By default cargo test captures all stdout from tests and only shows it when a
test fails. The flags below change that behaviour.
# show println! output even for passing tests cargo test -- --show-output # run tests one at a time (useful when tests share global state) cargo test -- --test-threads=1 # do not capture stdout at all (output appears immediately) cargo test -- --nocapture
Ignoring Slow Tests with #[ignore]
Tag a test with #[ignore] to skip it during the normal cargo test run. This is
useful for tests that hit a network, write to disk, or simply take a long time. You
can still run ignored tests explicitly when needed.
#[test]
#[ignore = "slow: downloads large dataset"]
fn test_large_download() {
// ...
}
// Run ONLY the ignored tests:
// cargo test -- --ignored
// Run ALL tests including ignored:
// cargo test -- --include-ignoredTest Helpers and Fixtures
For tests that share setup logic, extract a plain setup() function and call it at
the start of each test. When tests need temporary files, use the tempfile crate.
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> Vec<i32> {
vec![1, 2, 3, 4, 5]
}
#[test]
fn test_sum() {
let data = setup();
assert_eq!(data.iter().sum::<i32>(), 15);
}
#[test]
fn test_len() {
let data = setup();
assert_eq!(data.len(), 5);
}
}Mocking with the mockall Crate
Rust does not have built-in mocking, but the mockall crate provides a powerful
macro-based approach. Add it as a dev dependency — compiled only during tests —
and annotate your trait with #[automock].
[dev-dependencies] mockall = "0.13"
use mockall::automock;
#[automock]
trait Database {
fn fetch_user(&self, id: u32) -> Option<String>;
}
#[test]
fn test_with_mock() {
let mut mock = MockDatabase::new();
mock.expect_fetch_user()
.with(mockall::predicate::eq(1))
.returning(|_| Some("Alice".to_string()));
assert_eq!(mock.fetch_user(1), Some("Alice".to_string()));
}mockall, tempfile, or proptest only when you need capabilities beyond basic assertions — and even then, a single cargo add --dev is all it takes.