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.
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.
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.
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 --.
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.
//! 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.
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.
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.
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 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
List all Rust tutorials.