Skip to main content
Rust · lesson 25 of 30
Rust beginner Lesson 25 of 30

Also 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.

  1. Let a test find the bug. This average passes 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.

  1. Fix two wrong should_panic tests. 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.

  1. 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.

  1. 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.

  1. 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 the None case.

    /// 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.

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.