ZetCode

Rust testing

last modified November 13, 2026

In this article we show how to write and run tests in Rust.

Rust has testing built into the language and into Cargo; no external framework is needed. A test is a function marked with the #[test] attribute. The test passes if the function returns normally and fails if it panics. The cargo test command compiles the code in test mode and runs all tests.

There are three kinds of tests. Unit tests live next to the code in the same file and can test private functions. Integration tests live in the tests directory and use the library like any other crate. Doc-tests are code examples in documentation comments.

The examples were tested with Rust 1.97 and the 2024 edition. Each one is a library package created with cargo new --lib.

A first test

We create a library with one function and two tests.

src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[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(-4, -6), -10);
    }
}

The add function is the code under test. The tests are placed in a module at the end of the file.

#[cfg(test)]
mod tests {
    use super::*;

The #[cfg(test)] attribute compiles the module only when running tests, so the tests do not end up in the normal build. The module is a child of the library root; use super::* imports everything from the parent, including private items.

#[test]
fn adds_two_numbers() {
    assert_eq!(add(2, 3), 5);
}

A test function has no parameters. The assert_eq! macro compares two values and panics if they differ, which makes the test fail.

The cargo test command builds a test executable and runs it. Each test is reported with its module path. The second part of the output belongs to the doc-tests; this library has none.

$ cargo test
   Compiling adder v0.1.0 (/home/user/projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.11s
     Running unittests src/lib.rs (target/debug/deps/adder-e4d780f3ac01ce68)

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

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Assertions

The standard library has three assertion macros: assert! checks that a condition is true, assert_eq! that two values are equal, and assert_ne! that they are not.

src/lib.rs
pub fn is_palindrome(s: &str) -> bool {
    let chars: Vec<char> = s
        .chars()
        .filter(|c| c.is_alphanumeric())
        .map(|c| c.to_ascii_lowercase())
        .collect();

    chars.iter().eq(chars.iter().rev())
}

pub fn initials(name: &str) -> String {
    name.split_whitespace()
        .filter_map(|word| word.chars().next())
        .collect()
}

pub fn word_count(s: &str) -> usize {
    s.split_whitespace().count()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn palindromes() {
        assert!(is_palindrome("kayak"));
        assert!(is_palindrome("Was it a car or a cat I saw?"));
        assert!(!is_palindrome("falcon"), "falcon is not a palindrome");
    }

    #[test]
    fn counts_words() {
        assert_eq!(word_count("an old  falcon "), 3);
        assert_ne!(word_count("sky"), 0);
    }

    #[test]
    fn makes_initials() {
        let name = "john ronald reuel tolkien";
        assert_eq!(initials(name), "JRRT", "initials of {name:?}");
    }
}

The library has three string functions. The initials function has a bug: it does not convert the letters to uppercase.

assert!(is_palindrome("kayak"));
assert!(!is_palindrome("falcon"), "falcon is not a palindrome");

All assertion macros accept an optional message after the required arguments. The message is a format string with arguments, like in println!. It is printed only when the assertion fails.

assert_eq!(word_count("an old  falcon "), 3);
assert_ne!(word_count("sky"), 0);

The assert_eq! and assert_ne! macros need values that implement PartialEq for the comparison and Debug for the failure message. A test may contain any number of assertions; it stops at the first one that fails.

When a test fails, cargo test prints the panic message with the two compared values, labeled left and right, and the source line of the assertion. The other tests still run. The command exits with a non-zero status, which fails a CI pipeline.

$ cargo test
   Compiling strutil v0.1.0 (/home/user/projects/strutil)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.17s
     Running unittests src/lib.rs (target/debug/deps/strutil-3c897679e2188d2c)

running 3 tests
test tests::counts_words ... ok
test tests::palindromes ... ok
test tests::makes_initials ... FAILED

failures:

---- tests::makes_initials stdout ----

thread 'tests::makes_initials' (117591) panicked at src/lib.rs:41:9:
assertion `left == right` failed: initials of "john ronald reuel tolkien"
  left: "jrrt"
 right: "JRRT"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::makes_initials

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`

Fixing the bug

We fix the initials function and run the tests again.

pub fn initials(name: &str) -> String {
    name.split_whitespace()
        .filter_map(|word| word.chars().next())
        .flat_map(char::to_uppercase)
        .collect()
}

The char::to_uppercase method returns an iterator, because some characters become several characters in uppercase, so we use flat_map.

The -q option makes the output shorter: a dot for each passed test instead of a line.

$ 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

Testing for panics

Some functions are supposed to panic on invalid input. The #[should_panic] attribute turns the logic around: the test passes only if the function panics.

src/lib.rs
use std::fmt;

#[derive(Debug, PartialEq)]
pub struct Fraction {
    num: i64,
    den: i64,
}

impl Fraction {
    pub fn new(num: i64, den: i64) -> Self {
        if den == 0 {
            panic!("denominator must not be zero");
        }

        let g = gcd(num.abs(), den.abs());
        let sign = den.signum();

        Fraction {
            num: sign * num / g,
            den: sign * den / g,
        }
    }

    pub fn parse(s: &str) -> Result<Self, String> {
        let (n, d) = s.split_once('/').ok_or(format!("missing '/' in {s:?}"))?;
        let num: i64 = n.trim().parse().map_err(|e| format!("{n:?}: {e}"))?;
        let den: i64 = d.trim().parse().map_err(|e| format!("{d:?}: {e}"))?;

        if den == 0 {
            return Err("zero denominator".to_string());
        }

        Ok(Fraction::new(num, den))
    }
}

impl fmt::Display for Fraction {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}/{}", self.num, self.den)
    }
}

fn gcd(a: i64, b: i64) -> i64 {
    if b == 0 { a } else { gcd(b, a % b) }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reduces() {
        assert_eq!(Fraction::new(6, 8), Fraction::new(3, 4));
        assert_eq!(Fraction::new(1, -2).to_string(), "-1/2");
    }

    #[test]
    #[should_panic]
    fn zero_denominator() {
        Fraction::new(1, 0);
    }

    #[test]
    #[should_panic(expected = "must not be zero")]
    fn zero_denominator_message() {
        Fraction::new(5, 0);
    }

    #[test]
    fn parses() -> Result<(), String> {
        let f = Fraction::parse("10 / 4")?;
        assert_eq!(f.to_string(), "5/2");
        Ok(())
    }

    #[test]
    fn rejects_bad_input() {
        assert_eq!(Fraction::parse("3"), Err("missing '/' in \"3\"".to_string()));
        assert!(Fraction::parse("ten/4").is_err());
        assert!(Fraction::parse("1/0").is_err());
    }
}

The Fraction type keeps fractions in their lowest terms. The new function panics on a zero denominator; the parse function returns an error instead.

#[test]
#[should_panic]
fn zero_denominator() {
    Fraction::new(1, 0);
}

This test passes if Fraction::new(1, 0) panics, for whatever reason.

#[test]
#[should_panic(expected = "must not be zero")]
fn zero_denominator_message() {
    Fraction::new(5, 0);
}

The expected argument is more precise: the panic message must contain the given text. A plain #[should_panic] would also pass if the function panicked for an unrelated reason, such as an arithmetic overflow.

#[test]
fn parses() -> Result<(), String> {
    let f = Fraction::parse("10 / 4")?;
    assert_eq!(f.to_string(), "5/2");
    Ok(())
}

A test can return Result<(), E> where E implements Debug. Then the ? operator can be used instead of unwrap; the test fails if it returns Err. A test that returns Result cannot be combined with #[should_panic].

assert_eq!(Fraction::parse("3"), Err("missing '/' in \"3\"".to_string()));

Error cases deserve tests too. Since Fraction derives PartialEq and Debug, the whole Result can be compared. The panicking tests are marked with should panic in the output.

$ cargo test --lib
   Compiling fraction v0.1.0 (/home/user/projects/fraction)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.17s
     Running unittests src/lib.rs (target/debug/deps/fraction-a64fef2e810017e4)

running 5 tests
test tests::parses ... ok
test tests::reduces ... ok
test tests::rejects_bad_input ... ok
test tests::zero_denominator - should panic ... ok
test tests::zero_denominator_message - should panic ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Running a subset of tests

Arguments to cargo test select which tests run. Arguments for the test executable itself come after --.

src/lib.rs
pub fn is_prime(n: u64) -> bool {
    if n < 2 {
        return false;
    }

    let mut i = 2;
    while i * i <= n {
        if n.is_multiple_of(i) {
            return false;
        }
        i += 1;
    }

    true
}

pub fn nth_prime(n: usize) -> u64 {
    (2..).filter(|&x| is_prime(x)).nth(n - 1).unwrap()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn small_primes() {
        for n in [2, 3, 5, 7, 11, 13] {
            assert!(is_prime(n), "{n} is a prime");
        }
    }

    #[test]
    fn small_composites() {
        for n in [0, 1, 4, 9, 15, 49] {
            assert!(!is_prime(n), "{n} is not a prime");
        }
    }

    #[test]
    fn prime_count_below_100() {
        let count = (0..100).filter(|&n| is_prime(n)).count();
        println!("primes below 100: {count}");
        assert_eq!(count, 25);
    }

    #[test]
    #[ignore = "slow"]
    fn prime_number_300000() {
        assert_eq!(nth_prime(300_000), 4_256_233);
    }
}

The library checks numbers for primality by trial division. The tests use loops over arrays of cases, so one test covers several inputs; the message shows which input failed.

#[test]
#[ignore = "slow"]
fn prime_number_300000() {
    assert_eq!(nth_prime(300_000), 4_256_233);
}

The #[ignore] attribute skips a test unless asked for explicitly. It is used for slow or environment-dependent tests. The reason is optional and is printed in the test output.

The --list option lists the tests without running them. A plain argument is a filter: only the tests whose full name contains the text run. The --exact option requires an exact match of the full name.

$ cargo test -q --lib -- --list
tests::prime_count_below_100: test
tests::prime_number_300000: test
tests::small_composites: test
tests::small_primes: test
$ cargo test -q small

running 2 tests
..
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

$ cargo test -q --lib -- --exact tests::small_primes

running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s

Running ignored tests

The --ignored option runs only the ignored tests; --include-ignored runs all of them.

The ignored test computes the 300,000th prime, which takes a while in the unoptimized test build. The time is reported at the end of the summary line.

$ cargo test --lib -- --ignored
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/primes-c7d8586176a94195)

running 1 test
test tests::prime_number_300000 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 1.61s

Test output

By default, the test harness captures everything a test prints. The output is shown only for failing tests, so passing tests do not clutter the report.

let count = (0..100).filter(|&n| is_prime(n)).count();
println!("primes below 100: {count}");

The prime_count_below_100 test prints the count. In a normal run the line does not appear. With the --show-output option, the captured output of passed tests is printed after the results. The --nocapture option disables capturing, so the output appears immediately, interleaved with the test names.

Tests run in parallel threads by default. Tests that share a resource, such as a file or an environment variable, can be run one after another with -- --test-threads=1.

$ cargo test -q count

running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s

$ cargo test -q count -- --show-output

running 1 test
.
successes:

---- tests::prime_count_below_100 stdout ----
primes below 100: 25


successes:
    tests::prime_count_below_100

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s

Integration tests

Integration tests are placed in the tests directory next to src. Cargo compiles each file in it as a separate crate that uses the library through its public API, as an external user would.

src/lib.rs
//! Temperature conversions.

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Unit {
    Celsius,
    Fahrenheit,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Temperature {
    pub value: f64,
    pub unit: Unit,
}

/// Converts degrees Celsius to degrees Fahrenheit.
///
/// ```
/// assert_eq!(temperature::to_fahrenheit(100.0), 212.0);
/// ```
pub fn to_fahrenheit(celsius: f64) -> f64 {
    celsius * 9.0 / 5.0 + 32.0
}

/// Converts degrees Fahrenheit to degrees Celsius.
pub fn to_celsius(fahrenheit: f64) -> f64 {
    (fahrenheit - 32.0) * 5.0 / 9.0
}

/// Parses a temperature such as `21.5C` or `70F`.
///
/// ```
/// use temperature::{Unit, parse};
///
/// let t = parse("21.5C")?;
/// assert_eq!(t.value, 21.5);
/// assert_eq!(t.unit, Unit::Celsius);
/// # Ok::<(), String>(())
/// ```
pub fn parse(s: &str) -> Result<Temperature, String> {
    let s = s.trim();
    let unit = unit_of(s).ok_or_else(|| format!("unknown unit in {s:?}"))?;
    let value = s[..s.len() - 1]
        .parse::<f64>()
        .map_err(|e| format!("bad number in {s:?}: {e}"))?;

    Ok(Temperature { value, unit })
}

fn unit_of(s: &str) -> Option<Unit> {
    match s.chars().last()? {
        'C' | 'c' => Some(Unit::Celsius),
        'F' | 'f' => Some(Unit::Fahrenheit),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unit_suffix() {
        assert_eq!(unit_of("20C"), Some(Unit::Celsius));
        assert_eq!(unit_of("20f"), Some(Unit::Fahrenheit));
        assert_eq!(unit_of("20K"), None);
        assert_eq!(unit_of(""), None);
    }

    #[test]
    fn known_points() {
        let cases = [(-40.0, -40.0), (0.0, 32.0), (37.0, 98.6), (100.0, 212.0)];

        for (c, f) in cases {
            let got = to_fahrenheit(c);
            assert!((got - f).abs() < 1e-9, "{c} C should be {f} F, got {got}");
        }
    }
}

The temperature library converts and parses temperatures. Its unit tests check the private unit_of function, which an integration test cannot reach.

tests/conversion.rs
mod common;

use common::approx_eq;
use temperature::{to_celsius, to_fahrenheit};

#[test]
fn round_trip() {
    for c in [-273.15, -40.0, 0.0, 21.5, 100.0] {
        assert!(approx_eq(to_celsius(to_fahrenheit(c)), c));
    }
}

#[test]
fn body_temperature() {
    assert!(approx_eq(to_celsius(98.6), 37.0));
}

The file needs no #[cfg(test)] or tests module; Cargo compiles the files in tests only for cargo test. The library is imported by its package name. Floating-point results are compared with a tolerance, because to_celsius(98.6) is not exactly 37.0.

tests/parsing.rs
use temperature::{Unit, parse};

#[test]
fn parses_fahrenheit() {
    let t = parse(" 70F ").unwrap();
    assert_eq!(t.value, 70.0);
    assert_eq!(t.unit, Unit::Fahrenheit);
}

#[test]
fn rejects_unknown_unit() {
    let err = parse("300K").unwrap_err();
    assert!(err.contains("unknown unit"), "unexpected error: {err}");
}

#[test]
fn rejects_bad_number() {
    assert!(parse("warmC").is_err());
}

The second file tests parsing. The unwrap_err method returns the error of a Result and panics if there is none.

Shared helper code

Each file in tests is its own crate. Helper code shared by several test files is placed in a subdirectory, so Cargo does not treat it as a test crate.

tests/common/mod.rs
pub fn approx_eq(a: f64, b: f64) -> bool {
    (a - b).abs() < 1e-9
}

The tests/common/mod.rs file contains the comparison helper. The conversion.rs file declares it with mod common;. Had we named the file tests/common.rs, Cargo would compile it as a test crate of its own and report it with zero tests.

The cargo test command now runs the unit tests, one test executable per integration test file, and the doc-tests, in this order.

$ tree --noreport
.
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    ├── conversion.rs
    └── parsing.rs
$ cargo test
   Compiling temperature v0.1.0 (/home/user/projects/temperature)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.24s
     Running unittests src/lib.rs (target/debug/deps/temperature-5dd91fe33ef9a82c)

running 2 tests
test tests::known_points ... ok
test tests::unit_suffix ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/conversion.rs (target/debug/deps/conversion-f7d56ba1000abb70)

running 2 tests
test body_temperature ... ok
test round_trip ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/parsing.rs (target/debug/deps/parsing-aaf8e0998d44f8a0)

running 3 tests
test parses_fahrenheit ... ok
test rejects_bad_number ... ok
test rejects_unknown_unit ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests temperature

running 2 tests
test src/lib.rs - to_fahrenheit (line 17) ... ok
test src/lib.rs - parse (line 31) ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

all doctests ran in 0.13s; merged doctests compilation took 0.13s

Selecting the kind of tests

The --lib option runs only the unit tests of the library, --test name one integration test file, and --doc only the doc-tests. A filter can be added as before.

$ cargo test -q --test parsing

running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

$ cargo test -q --test conversion body

running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

Doc-tests

Code blocks in documentation comments are compiled and run as doc-tests. They keep the examples in the documentation working.

/// Parses a temperature such as `21.5C` or `70F`.
///
/// ```
/// use temperature::{Unit, parse};
///
/// let t = parse("21.5C")?;
/// assert_eq!(t.value, 21.5);
/// assert_eq!(t.unit, Unit::Celsius);
/// # Ok::<(), String>(())
/// ```

Each code block is compiled as a separate small program that uses the library, wrapped in a generated main function. Lines starting with # are compiled but hidden in the generated documentation. Here the hidden last line makes the wrapper return a Result, so the visible example can use the ? operator. Code blocks can be marked as should_panic, no_run (compile only), or ignore.

The output names each doc-test by the file and the line of its code block. Doc-tests exist only for library crates; examples in a binary crate's comments are not run.

$ cargo test --doc
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
   Doc-tests temperature

running 2 tests
test src/lib.rs - to_fahrenheit (line 17) ... ok
test src/lib.rs - parse (line 31) ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

all doctests ran in 0.13s; merged doctests compilation took 0.13s

Source

The Rust Programming Language: Writing Automated Tests

The Cargo Book: cargo test

The rustdoc book: Documentation tests

In this article we have written unit tests, integration tests, and doc-tests in Rust. Modules and library crates are covered in Rust modules and crates, the Result type in Rust error handling.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.