Skip to main content
Rust beginner Lesson 25 of 30

Testing in Rust

Write unit tests, integration tests, and doc tests in Rust using cargo test.

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

Frequently Asked Questions

Where do unit tests go in Rust?
Unit tests live in the same file as the code they test, inside a #[cfg(test)] mod tests block. This gives them access to private items.
What is the difference between unit tests and integration tests?
Unit tests test internal implementation details and live in the source file. Integration tests test the public API from an external perspective and live in the tests/ directory.
What are doc tests?
Doc tests are code examples in documentation comments (///) that are compiled and run by cargo test. They keep documentation examples up to date.