Rust · lesson 25 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Also part of: Software Testing
Testing in Rust
Write unit tests, integration tests, and doc tests in Rust using cargo test.
By SkillByExample · Updated
Unit Tests
Rust builds testing directly into the language and toolchain — no extra framework to install. Unit tests live in the same file as the code they test, inside a #[cfg(test)] block. This placement is intentional: tests have access to private functions and types that external code cannot reach, letting you verify internal behavior without making it public.
// src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}
fn is_even(n: i32) -> bool { n % 2 == 0 }
#[cfg(test)] // this entire block is excluded from release builds
mod tests {
use super::*; // import everything from the parent module, including private items
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
assert_eq!(add(-1, 1), 0);
assert_eq!(add(0, 0), 0);
}
#[test]
fn test_divide() {
assert_eq!(divide(10.0, 2.0), Some(5.0));
assert_eq!(divide(7.0, 0.0), None);
}
#[test]
fn test_private_is_even() {
// Unit tests can reach private functions — integration tests cannot
assert!(is_even(4));
assert!(!is_even(3));
}
}
Run tests:
cargo test # run all tests
cargo test test_add # run only tests whose name contains "test_add"
cargo test -- --nocapture # show println! output from passing tests
Assertion Macros
Rust’s assertion macros give you clear failure messages when a test fails. assert_eq! and assert_ne! print both values on failure, making it easy to see what went wrong without adding extra debug prints. The custom message parameter lets you add context that explains what the test was checking.
#[cfg(test)]
mod tests {
#[test]
fn assertion_examples() {
// assert_eq! prints both values on failure — much more useful than assert!
assert_eq!(2 + 2, 4);
assert_ne!(2 + 2, 5);
// assert! for any boolean condition
assert!(10 > 5);
assert!(!false);
// Custom message appears in the failure output for better diagnosis
let x = 5;
assert_eq!(x, 5, "expected x to be 5, got {}", x);
// Floating point: never use == due to rounding; check within a tolerance
let result = 0.1 + 0.2;
assert!((result - 0.3).abs() < 1e-10, "float comparison failed: {}", result);
}
}
Testing for Panics
Some functions are supposed to panic on invalid input — like indexing out of bounds or passing a zero to a function that requires a positive value. #[should_panic] verifies that the panic actually happens. Adding expected = "..." is better practice: it checks that the panic message contains the expected text, so a panic from a completely different code path does not silently pass the test.
pub fn index_into(v: &[i32], i: usize) -> i32 {
if i >= v.len() {
panic!("index {} out of bounds (len={})", i, v.len());
}
v[i]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn test_panics_on_oob() {
index_into(&[1, 2, 3], 10);
}
#[test]
#[should_panic(expected = "out of bounds")]
fn test_panics_with_message() {
// Test fails if no panic occurs, OR if the panic message does not contain "out of bounds"
index_into(&[1, 2, 3], 10);
}
}
Testing Result Returns
Tests can return Result<(), E> instead of (). When a test returns Err, it fails with the error message displayed — no need for unwrap() everywhere. The ? operator works inside these tests, so you can chain fallible operations cleanly and get the exact failing line in the output.
use std::num::ParseIntError;
pub fn parse_positive(s: &str) -> Result<u32, String> {
let n: i64 = s.parse().map_err(|e: ParseIntError| e.to_string())?;
if n < 0 { return Err(format!("{} is negative", n)); }
Ok(n as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_positive() -> Result<(), String> {
// ? propagates errors — test fails with a clear message if any assertion fails
assert_eq!(parse_positive("42")?, 42);
assert_eq!(parse_positive("0")?, 0);
Ok(())
}
#[test]
fn test_parse_errors() {
assert!(parse_positive("-5").is_err());
assert!(parse_positive("abc").is_err());
// Check the error message contains expected text
let err = parse_positive("-1").unwrap_err();
assert!(err.contains("negative"), "unexpected error: {}", err);
}
}
Ignored and Marked Tests
Some tests are too slow to run on every cargo test invocation — integration tests hitting a real database, or tests that simulate network conditions. Mark them #[ignore] to opt them out of the default run. They still live in the test suite and can be run explicitly when needed, keeping the fast feedback loop intact for everyday development.
#[cfg(test)]
mod tests {
#[test]
#[ignore = "hits the real database — run with cargo test -- --ignored"]
fn slow_integration_test() {
// Run this explicitly with: cargo test -- --ignored
// or run everything including ignored: cargo test -- --include-ignored
std::thread::sleep(std::time::Duration::from_secs(30));
}
}
Integration Tests
Integration tests live in tests/ at the project root and are compiled as separate crates that only have access to the public API. They test your library the same way an external user would. Each file in tests/ is an independent test crate — cargo test compiles and runs them all.
project/
├── src/
│ └── lib.rs
└── tests/
├── basic.rs
└── advanced.rs
tests/basic.rs
// No #[cfg(test)] needed — this entire file is only compiled during testing
use my_project::{add, divide};
#[test]
fn integration_add() {
assert_eq!(add(100, 200), 300);
}
#[test]
fn integration_divide_by_zero() {
assert_eq!(divide(5.0, 0.0), None);
}
Shared test helpers go in tests/common/mod.rs — the common/mod.rs path prevents Cargo from treating common as a test file:
tests/
├── common/
│ └── mod.rs # shared fixtures and helpers
├── basic.rs
└── advanced.rs
tests/common/mod.rs
pub fn setup() -> Vec<i32> {
vec![1, 2, 3, 4, 5]
}
tests/basic.rs
mod common;
#[test]
fn test_with_fixture() {
let data = common::setup();
assert_eq!(data.len(), 5);
}
Doc Tests
Doc tests solve the problem of documentation that lies — examples in comments that worked when written but broke as the code evolved. Because doc tests are compiled and run by cargo test, any example that no longer compiles or produces the wrong output fails the test suite. This keeps your documentation accurate at zero extra effort.
/// Adds two numbers together.
///
/// # Examples
///
/// ```
/// use my_project::add;
///
/// assert_eq!(add(2, 3), 5);
/// assert_eq!(add(-1, 1), 0);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// Divides two numbers, returning None for division by zero.
///
/// ```
/// use my_project::safe_divide;
///
/// assert_eq!(safe_divide(10.0, 2.0), Some(5.0));
/// assert_eq!(safe_divide(5.0, 0.0), None);
/// ```
pub fn safe_divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}
Doc tests can also document panics:
/// # Panics
///
/// Panics if `n` is zero.
///
/// ```should_panic
/// my_project::reciprocal(0);
/// ```
pub fn reciprocal(n: i32) -> f64 {
if n == 0 { panic!("cannot take reciprocal of zero"); }
1.0 / n as f64
}
Useful cargo test Flags
cargo test # run all tests
cargo test foo # run tests whose name contains "foo"
cargo test -- --nocapture # show stdout from passing tests
cargo test -- --test-threads=1 # run tests sequentially (useful for tests sharing state)
cargo test -- --ignored # run only ignored tests
cargo test --doc # run only doc tests
cargo test --test integration # run only tests/integration.rs
cargo test --lib # run only unit tests in src/
cargo test --release # test in release mode (catches optimization-dependent bugs)
Test Coverage with cargo-tarpaulin
Coverage tools show which lines of code are exercised by the test suite, helping you find untested paths. cargo-tarpaulin is the most popular coverage tool for Rust on Linux.
cargo install cargo-tarpaulin
cargo tarpaulin --out Html # generates coverage.html
Benchmarks
Benchmarks measure the performance of specific functions so you can catch regressions and verify optimizations. The criterion crate provides statistically rigorous benchmarking — it runs each benchmark many times, reports mean and variance, and detects significant changes between runs.
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "my_bench"
harness = false
benches/my_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use my_project::add;
fn bench_add(c: &mut Criterion) {
// black_box prevents the compiler from optimizing away the computation
c.bench_function("add i32", |b| {
b.iter(|| add(black_box(2), black_box(3)))
});
}
criterion_group!(benches, bench_add);
criterion_main!(benches);
cargo bench
Practice
Each task is a small library crate named my_project. cargo test -q prints one character per test
(. pass, i ignored). The second running 0 tests block in each run is the doc-test pass, which
cargo test always runs for a library.
-
Let a test find the bug. This
averagepasses two of its three tests. Run them, read the failure, and fix the function, not the test.pub fn average(values: &[i32]) -> Option<f64> { if values.is_empty() { return None; } let sum: i32 = values.iter().sum(); Some((sum / values.len() as i32) as f64) } #[cfg(test)] mod tests { use super::*; #[test] fn average_of_empty_is_none() { assert_eq!(average(&[]), None); } #[test] fn average_of_whole_result() { assert_eq!(average(&[2, 4, 6]), Some(4.0)); } #[test] fn average_of_fractional_result() { assert_eq!(average(&[1, 2]), Some(1.5), "average of 1 and 2"); } }
Solution
pub fn average(values: &[i32]) -> Option<f64> {
if values.is_empty() {
return None;
}
let sum: i32 = values.iter().sum();
Some(sum as f64 / values.len() as f64)
}
$ cargo test -q
running 3 tests
.. 2/3
tests::average_of_fractional_result --- FAILED
failures:
---- tests::average_of_fractional_result stdout ----
thread 'tests::average_of_fractional_result' (93) panicked at src/lib.rs:25:9:
assertion `left == right` failed: average of 1 and 2
left: Some(1.0)
right: Some(1.5)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::average_of_fractional_result
test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
$ cargo test -q
running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
assert_eq! printed both sides plus the custom message, so the bug is easy to read: 3 / 2 is integer
division and gives 1 before the cast to f64. Converting before dividing fixes it. The whole-number
test passed with the buggy code, which is why you test the edge cases too.
-
Fix two wrong
should_panictests. Both of these fail, for different reasons. Fix each one so it tests what its name says.#[test] #[should_panic(expected = "out of range")] fn test_panics_with_message() { index_into(&[1, 2, 3], 10); } #[test] #[should_panic(expected = "out of bounds")] fn test_in_bounds_does_not_panic() { index_into(&[1, 2, 3], 2); }
Solution
#[test]
#[should_panic(expected = "out of bounds")]
fn test_panics_with_message() {
index_into(&[1, 2, 3], 10);
}
#[test]
fn test_in_bounds_returns_value() {
assert_eq!(index_into(&[1, 2, 3], 2), 3);
}
$ cargo test -q
running 2 tests
tests::test_in_bounds_does_not_panic --- FAILED
tests::test_panics_with_message --- FAILED
failures:
---- tests::test_in_bounds_does_not_panic stdout ----
note: test did not panic as expected at src/lib.rs:20:8
---- tests::test_panics_with_message stdout ----
thread 'tests::test_panics_with_message' (83) panicked at src/lib.rs:3:9:
index 10 out of bounds (len=3)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
panic message: "index 10 out of bounds (len=3)"
expected substring: "out of range"
failures:
tests::test_in_bounds_does_not_panic
tests::test_panics_with_message
test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
$ cargo test -q
running 2 tests
..
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The first test did panic, but expected is a substring match and “out of range” isn’t in the message.
The second test failed because nothing panicked, and should_panic treats that as a failure. A test
that checks the normal path should assert the return value instead.
-
Watch
?fail a test. This test sums a list of parsed numbers, but one of them is negative. Run it, then split it into a test of the valid inputs and a separate test that the negative one is rejected with the right message.#[test] fn test_parse_list() -> Result<(), String> { let mut total = 0; for s in ["4", "10", "-3", "7"] { total += parse_positive(s)?; } assert_eq!(total, 21); Ok(()) }
Solution
#[test]
fn test_parse_list() -> Result<(), String> {
let mut total = 0;
for s in ["4", "10", "7"] {
total += parse_positive(s)?;
}
assert_eq!(total, 21);
Ok(())
}
#[test]
fn test_negative_is_rejected() {
assert_eq!(parse_positive("-3"), Err(String::from("-3 is negative")));
}
$ cargo test -q
running 1 test
tests::test_parse_list --- FAILED
failures:
---- tests::test_parse_list stdout ----
Error: "-3 is negative"
failures:
tests::test_parse_list
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
$ cargo test -q
running 2 tests
..
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
When ? hits an Err, the test returns it, and the harness reports the error value instead of a panic,
with no line number. That suits setup steps that shouldn’t fail. When the error is the behaviour you are
testing, assert on it directly as the second test does.
- Skip the slow test by default. Mark a test that sleeps for 2 seconds with
#[ignore]and a reason. Run the suite normally, then with-- --ignored, then with-- --include-ignored, and compare the timings.
Solution
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fast_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
#[ignore = "slow: sleeps for 2 seconds"]
fn slow_add() {
std::thread::sleep(std::time::Duration::from_secs(2));
assert_eq!(add(20, 22), 42);
}
}
$ cargo test -q
running 2 tests
i.
test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
$ cargo test -q -- --ignored
running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 2.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
$ cargo test -q -- --include-ignored
running 2 tests
..
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.00s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
The default run took 0.00s and listed the slow test as i. --ignored runs only the ignored tests, so
fast_add counts as filtered out. --include-ignored runs both. Use the last one in CI so slow tests
still get run somewhere.
-
Fix a lying doc test. This doc comment shows an example that is wrong. Run
cargo test --doc, then fix the example so it documents the real behaviour, including theNonecase./// Divides two numbers, returning None for division by zero. /// /// ``` /// use my_project::safe_divide; /// /// assert_eq!(safe_divide(10.0, 4.0), Some(2.5)); /// assert_eq!(safe_divide(1.0, 3.0), Some(0.333)); /// ``` pub fn safe_divide(a: f64, b: f64) -> Option<f64> { if b == 0.0 { None } else { Some(a / b) } }
Solution
/// Divides two numbers, returning None for division by zero.
///
/// ```
/// use my_project::safe_divide;
///
/// assert_eq!(safe_divide(10.0, 4.0), Some(2.5));
/// let third = safe_divide(1.0, 3.0).unwrap();
/// assert!((third - 0.333).abs() < 1e-3);
/// assert_eq!(safe_divide(5.0, 0.0), None);
/// ```
pub fn safe_divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}
$ cargo test --doc
Compiling my_project v0.1.0 (/src)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.48s
Doc-tests my_project
running 1 test
test src/lib.rs - safe_divide (line 3) ... FAILED
failures:
---- src/lib.rs - safe_divide (line 3) stdout ----
Test executable failed (exit status: 101).
stderr:
thread 'main' (65) panicked at /tmp/rustdoctestJ1DQoX/doctest_bundle_2024.rs:9:1:
assertion `left == right` failed
left: Some(0.3333333333333333)
right: Some(0.333)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
src/lib.rs - safe_divide (line 3)
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
all doctests ran in 0.72s; merged doctests compilation took 0.67s
error: doctest failed, to rerun pass `--doc`
$ cargo test --doc
Compiling my_project v0.1.0 (/src)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.54s
Doc-tests my_project
running 1 test
test src/lib.rs - safe_divide (line 3) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
all doctests ran in 0.69s; merged doctests compilation took 0.64s
The doc test is compiled as a separate program that uses your crate from outside, which is why it needs
use my_project::safe_divide. It is named after the source line where the code block starts. The fix
applies this lesson’s float rule, comparing within a tolerance and never with ==, so the documentation
now teaches the right habit as well.