Guide contents

Lesson 48 of 94

Testing

Rust has a test framework built into the compiler and Cargo. There's nothing to add to Cargo.toml before writing your first test: mark a function with #[test] and run cargo test.

Task Rust
mark a test #[test]
compare values assert_eq!(actual, expected)
check a condition assert!(x)
expect a failure #[should_panic], or check the Err a function returns
skip a test #[ignore = "reason"]
setup and teardown a helper function / a guard with Drop
parameterized cases a loop over a table of cases
mocking a hand-written fake that implements a trait (or the mockall crate)
where tests live a #[cfg(test)] mod tests next to the code, plus tests/ for integration tests
benchmarks the criterion crate

The examples below marked cargo test are compiled as a test binary and run; the output block shows exactly what cargo test prints for them. (Tests run on one thread here so the order is stable. Normally they run in parallel.)

1. Your first test

A test is a function with #[test] that takes no arguments. It passes if it returns normally and fails if it panics. Tests usually live in a child module called tests, marked #[cfg(test)] so it's only compiled when testing.

Rustcargo test
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*; // the parent module's items, including private ones

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

    #[test]
    fn adds_negative_numbers() {
        assert_eq!(add(-2, -3), -5);
    }
}
Output
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

Test names are printed with their module path (tests::adds_two_numbers). Name tests as sentences in snake_case; there's no need for a test prefix.

2. The assertion macros

assert! checks a boolean; assert_eq! and assert_ne! compare two values with == and print both on failure (so the type needs PartialEq and Debug). All three take an optional format message.

Rustcargo test
pub fn normalize_email(raw: &str) -> String {
    raw.trim().to_lowercase()
}

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

    #[test]
    fn trims_and_lowercases() {
        let email = normalize_email("  Ana@Example.COM ");
        assert_eq!(email, "ana@example.com");
        assert_ne!(email, "Ana@Example.COM");
        assert!(email.contains('@'), "expected an @ in {email:?}");
        assert!(!email.starts_with(' '));
    }
}
Output
running 1 test
test tests::trims_and_lowercases ... ok

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

Argument order doesn't matter to Rust (the failure shows left and right), but the convention is assert_eq!(actual, expected).

3. Reading a failure

When an assertion fails, the test panics and cargo test prints the left and right values, your message, and the file and line. The other tests keep running.

Rustcargo test · tests fail
pub fn word_count(text: &str) -> usize {
    text.split(' ').count() // bug: splits on single spaces only
}

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

    #[test]
    fn counts_simple_sentence() {
        assert_eq!(word_count("hello big world"), 3);
    }

    #[test]
    fn ignores_extra_spaces() {
        assert_eq!(word_count("hello   world "), 2, "input was {:?}", "hello   world ");
    }
}
Output
running 2 tests
test tests::counts_simple_sentence ... ok
test tests::ignores_extra_spaces ... FAILED

failures:

---- tests::ignores_extra_spaces stdout ----

thread 'tests::ignores_extra_spaces' panicked at src/main.rs:16:9:
assertion `left == right` failed: input was "hello   world "
  left: 5
 right: 2


failures:
    tests::ignores_extra_spaces

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

The fix is text.split_whitespace().count(). Note the exit status: cargo test fails the build (like a failing mvn test), so CI goes red.

4. Testing private functions

Because tests is a child module, use super::* gives it access to private functions. In Rust this is the default arrangement.

Rustcargo test
pub fn price_with_tax(cents: u64) -> u64 {
    cents + tax(cents)
}

// Private: not part of the public API, but still worth testing directly.
fn tax(cents: u64) -> u64 {
    (cents * 20 + 50) / 100 // 20%, rounded to the nearest cent
}

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

    #[test]
    fn tax_rounds_to_nearest_cent() {
        assert_eq!(tax(100), 20);
        assert_eq!(tax(3), 1); // 0.6 cents rounds up
        assert_eq!(tax(2), 0); // 0.4 cents rounds down
    }

    #[test]
    fn price_includes_tax() {
        assert_eq!(price_with_tax(1000), 1200);
    }
}
Output
running 2 tests
test tests::price_includes_tax ... ok
test tests::tax_rounds_to_nearest_cent ... ok

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

Whether to test private functions is a style question. Testing through the public API is more robust to refactoring; testing a tricky private helper directly gives more precise failures.

5. Expecting a panic: #[should_panic]

#[should_panic] passes only if the test panics. Add expected = "..." to require a substring of the panic message, so the test doesn't pass because of some unrelated panic.

Rustcargo test
pub struct Percentage(u8);

impl Percentage {
    pub fn new(value: u8) -> Percentage {
        assert!(value <= 100, "percentage must be at most 100, got {value}");
        Percentage(value)
    }

    pub fn of(&self, amount: u32) -> u32 {
        amount * self.0 as u32 / 100
    }
}

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

    #[test]
    fn computes_share() {
        assert_eq!(Percentage::new(25).of(200), 50);
    }

    #[test]
    #[should_panic(expected = "at most 100")]
    fn rejects_values_over_100() {
        Percentage::new(150);
    }
}
Output
running 2 tests
test tests::computes_share ... ok
test tests::rejects_values_over_100 - should panic ... ok

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

#[should_panic] is for code that panics by design. For code that returns Result, test the Err value instead (example 7).

6. Tests that return Result

A test can return Result<(), E>. Returning Err fails the test, which lets you use ? instead of unwrap() on every line.

Rustcargo test
use std::collections::HashMap;

pub fn parse_config(text: &str) -> Result<HashMap<String, String>, String> {
    let mut map = HashMap::new();
    for (n, line) in text.lines().enumerate() {
        let (k, v) = line.split_once('=').ok_or(format!("line {}: missing '='", n + 1))?;
        map.insert(k.trim().to_string(), v.trim().to_string());
    }
    Ok(map)
}

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

    #[test]
    fn reads_port_and_host() -> Result<(), String> {
        let cfg = parse_config("host = localhost\nport = 8080")?;
        let port: u16 = cfg["port"].parse().map_err(|e| format!("bad port: {e}"))?;
        assert_eq!(port, 8080);
        assert_eq!(cfg.get("host").map(String::as_str), Some("localhost"));
        Ok(())
    }
}
Output
running 1 test
test tests::reads_port_and_host ... ok

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

7. Testing the error path

For functions that return Result, assert on the error itself. unwrap_err() panics if the result is Ok, which fails the test with a useful message. matches! checks an enum variant without needing PartialEq.

Rustcargo test
#[derive(Debug, PartialEq)]
pub enum SignupError {
    TooShort { min: usize },
    Taken(String),
}

pub fn check_username(name: &str, existing: &[&str]) -> Result<(), SignupError> {
    if name.len() < 3 {
        return Err(SignupError::TooShort { min: 3 });
    }
    if existing.contains(&name) {
        return Err(SignupError::Taken(name.to_string()));
    }
    Ok(())
}

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

    #[test]
    fn accepts_new_name() {
        assert!(check_username("ana", &["bob"]).is_ok());
    }

    #[test]
    fn rejects_short_name() {
        let err = check_username("al", &[]).unwrap_err();
        assert_eq!(err, SignupError::TooShort { min: 3 });
    }

    #[test]
    fn rejects_taken_name() {
        let result = check_username("bob", &["bob"]);
        assert!(matches!(result, Err(SignupError::Taken(ref n)) if n == "bob"), "got {result:?}");
    }
}
Output
running 3 tests
test tests::accepts_new_name ... ok
test tests::rejects_short_name ... ok
test tests::rejects_taken_name ... ok

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

8. Ignoring slow tests

#[ignore] skips a test unless you ask for it with cargo test -- --ignored (only ignored tests) or cargo test -- --include-ignored (all). Give a reason; it's printed in the report.

Rustcargo test
pub fn fib(n: u64) -> u64 {
    if n < 2 { n } else { fib(n - 1) + fib(n - 2) }
}

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

    #[test]
    fn small_values() {
        assert_eq!((0..8).map(fib).collect::<Vec<_>>(), [0, 1, 1, 2, 3, 5, 8, 13]);
    }

    #[test]
    #[ignore = "takes several seconds"]
    fn large_value() {
        assert_eq!(fib(40), 102_334_155);
    }
}
Output
running 2 tests
test tests::large_value ... ignored, takes several seconds
test tests::small_values ... ok

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

9. Running a subset of tests

cargo test takes a name filter.

Terminal
cargo test                          # everything: unit, integration and doc tests
cargo test tax                      # tests whose path contains "tax"
cargo test tests::rejects_ -- --exact   # --exact: the full name must match
cargo test --lib                    # only unit tests in src/
cargo test --test checkout          # only tests/checkout.rs
cargo test --doc                    # only doc tests
cargo test -- --nocapture           # show println! output of passing tests
cargo test -- --test-threads=1      # run one at a time
cargo test -- --include-ignored     # also run #[ignore] tests

Arguments before -- go to Cargo; arguments after it go to the test binary.

10. Organizing tests in modules

Test modules nest like any other module, and the module path shows up in the output. Group tests by the behavior they cover.

Rustcargo test
pub struct Stack<T> {
    items: Vec<T>,
}

impl<T> Stack<T> {
    pub fn new() -> Self {
        Stack { items: Vec::new() }
    }
    pub fn push(&mut self, item: T) {
        self.items.push(item);
    }
    pub fn pop(&mut self) -> Option<T> {
        self.items.pop()
    }
    pub fn peek(&self) -> Option<&T> {
        self.items.last()
    }
    pub fn len(&self) -> usize {
        self.items.len()
    }
}

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

    mod when_empty {
        use super::*;

        #[test]
        fn pop_returns_none() {
            assert_eq!(Stack::<i32>::new().pop(), None);
        }

        #[test]
        fn len_is_zero() {
            assert_eq!(Stack::<i32>::new().len(), 0);
        }
    }

    mod after_pushes {
        use super::*;

        fn stack() -> Stack<&'static str> {
            let mut s = Stack::new();
            s.push("a");
            s.push("b");
            s
        }

        #[test]
        fn peek_shows_last() {
            assert_eq!(stack().peek(), Some(&"b"));
        }

        #[test]
        fn pop_is_lifo() {
            let mut s = stack();
            assert_eq!(s.pop(), Some("b"));
            assert_eq!(s.pop(), Some("a"));
            assert_eq!(s.pop(), None);
        }
    }
}
Output
running 4 tests
test tests::after_pushes::peek_shows_last ... ok
test tests::after_pushes::pop_is_lifo ... ok
test tests::when_empty::len_is_zero ... ok
test tests::when_empty::pop_returns_none ... ok

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

11. Fixtures: setup with helper functions

There's no @BeforeEach. Each test calls a helper that builds what it needs, which keeps every test self-contained and makes the setup visible. A builder-style helper lets each test tweak only what matters to it.

Rustcargo test
#[derive(Debug, Clone)]
pub struct Order {
    pub items: Vec<(String, u32)>,
    pub coupon: Option<String>,
    pub member: bool,
}

pub fn total(order: &Order) -> u32 {
    let subtotal: u32 = order.items.iter().map(|(_, cents)| cents).sum();
    let mut discount = 0;
    if order.member {
        discount += subtotal / 10;
    }
    if order.coupon.as_deref() == Some("SAVE5") {
        discount += 500;
    }
    subtotal.saturating_sub(discount)
}

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

    // The fixture: a typical order that tests adjust.
    fn order() -> Order {
        Order {
            items: vec![(String::from("book"), 2000), (String::from("pen"), 500)],
            coupon: None,
            member: false,
        }
    }

    #[test]
    fn plain_order() {
        assert_eq!(total(&order()), 2500);
    }

    #[test]
    fn member_gets_ten_percent() {
        assert_eq!(total(&Order { member: true, ..order() }), 2250);
    }

    #[test]
    fn coupon_and_member_stack() {
        let o = Order { member: true, coupon: Some(String::from("SAVE5")), ..order() };
        assert_eq!(total(&o), 1750);
    }

    #[test]
    fn discount_never_goes_negative() {
        let o = Order { items: vec![(String::from("sticker"), 100)], coupon: Some(String::from("SAVE5")), ..order() };
        assert_eq!(total(&o), 0);
    }
}
Output
running 4 tests
test tests::coupon_and_member_stack ... ok
test tests::discount_never_goes_negative ... ok
test tests::member_gets_ten_percent ... ok
test tests::plain_order ... ok

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

The struct update syntax ..order() is the key trick: "the standard fixture, except for these fields".

12. Teardown with Drop

For cleanup that must happen even when the test fails (@AfterEach), use a guard whose Drop does the cleanup. A failing assertion panics, and the panic still runs destructors as it unwinds.

Rustcargo test
use std::fs;
use std::path::PathBuf;

pub fn save_note(dir: &std::path::Path, name: &str, text: &str) -> std::io::Result<PathBuf> {
    let path = dir.join(format!("{name}.txt"));
    fs::write(&path, text)?;
    Ok(path)
}

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

    struct TempDir(PathBuf);

    impl TempDir {
        fn new(name: &str) -> TempDir {
            let path = std::env::temp_dir().join(format!("notes-test-{name}-{}", std::process::id()));
            fs::create_dir_all(&path).unwrap();
            TempDir(path)
        }
    }

    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0); // runs even if the test panicked
        }
    }

    #[test]
    fn writes_file() {
        let dir = TempDir::new("writes");
        let path = save_note(&dir.0, "todo", "buy milk").unwrap();
        assert_eq!(fs::read_to_string(path).unwrap(), "buy milk");
    }

    #[test]
    fn overwrites_existing() {
        let dir = TempDir::new("overwrites");
        save_note(&dir.0, "todo", "old").unwrap();
        let path = save_note(&dir.0, "todo", "new").unwrap();
        assert_eq!(fs::read_to_string(path).unwrap(), "new");
    }
}
Output
running 2 tests
test tests::overwrites_existing ... ok
test tests::writes_file ... ok

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

In real projects the tempfile crate provides exactly this guard (tempfile::tempdir()). Each test gets its own directory, which matters because tests run in parallel.

13. Table-driven tests

Instead of @ParameterizedTest with a @CsvSource, write a table of cases and loop over it. Include the case in the failure message so you know which row failed.

Rustcargo test
pub fn parse_duration(s: &str) -> Option<u64> {
    let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
    let n: u64 = num.parse().ok()?;
    let factor = match unit {
        "s" => 1,
        "m" => 60,
        "h" => 3600,
        "d" => 86_400,
        _ => return None,
    };
    n.checked_mul(factor)
}

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

    #[test]
    fn parses_durations() {
        let cases = [
            ("30s", Some(30)),
            ("5m", Some(300)),
            ("2h", Some(7200)),
            ("1d", Some(86_400)),
            ("10", None),   // no unit
            ("m", None),    // no number
            ("5w", None),   // unknown unit
            ("-5s", None),  // sign not allowed
        ];
        for (input, expected) in cases {
            assert_eq!(parse_duration(input), expected, "input: {input:?}");
        }
    }
}
Output
running 1 test
test tests::parses_durations ... ok

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

A table is one test, so the first failing row stops it. When each case deserves its own name in the report, a small macro_rules! can generate one #[test] per case; the Macros lesson shows how.

14. Comparing floats

Floating-point results are rarely exactly equal. Compare within a tolerance with a small helper.

Rustcargo test
pub fn mean(xs: &[f64]) -> f64 {
    xs.iter().sum::<f64>() / xs.len() as f64
}

pub fn std_dev(xs: &[f64]) -> f64 {
    let m = mean(xs);
    (xs.iter().map(|x| (x - m).powi(2)).sum::<f64>() / xs.len() as f64).sqrt()
}

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

    fn assert_close(actual: f64, expected: f64) {
        assert!((actual - expected).abs() < 1e-9, "expected {expected}, got {actual}");
    }

    #[test]
    fn tenths_do_not_add_up_exactly() {
        assert_ne!(0.1 + 0.2, 0.3);
        assert_close(0.1 + 0.2, 0.3);
    }

    #[test]
    fn statistics() {
        let data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
        assert_close(mean(&data), 5.0);
        assert_close(std_dev(&data), 2.0);
    }
}
Output
running 2 tests
test tests::statistics ... ok
test tests::tenths_do_not_add_up_exactly ... ok

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

15. Test-only code with #[cfg(test)]

Anything marked #[cfg(test)] exists only in test builds: helper constructors, extra trait impls, or a PartialEq you don't want in the public API.

Rustcargo test
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))] // equality only needed by the tests
pub struct Session {
    user: String,
    token: String,
}

impl Session {
    pub fn login(user: &str, password: &str) -> Option<Session> {
        (password == "hunter2").then(|| Session { user: user.to_string(), token: format!("tok-{user}") })
    }
}

#[cfg(test)]
impl Session {
    // A shortcut constructor that production code can't call.
    fn for_test(user: &str) -> Session {
        Session { user: user.to_string(), token: format!("tok-{user}") }
    }
}

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

    #[test]
    fn login_with_right_password() {
        assert_eq!(Session::login("ana", "hunter2"), Some(Session::for_test("ana")));
    }

    #[test]
    fn login_with_wrong_password() {
        assert_eq!(Session::login("ana", "letmein"), None);
    }
}
Output
running 2 tests
test tests::login_with_right_password ... ok
test tests::login_with_wrong_password ... ok

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

16. Fakes through traits

Rust has no runtime proxies, so code depends on a trait, and the test passes a small fake implementation.

Rustcargo test
use std::collections::HashMap;

pub trait UserRepo {
    fn find_email(&self, user_id: u32) -> Option<String>;
}

pub struct Notifier<R: UserRepo> {
    repo: R,
}

impl<R: UserRepo> Notifier<R> {
    pub fn new(repo: R) -> Self {
        Notifier { repo }
    }

    pub fn reminder(&self, user_id: u32) -> Result<String, String> {
        let email = self.repo.find_email(user_id).ok_or(format!("user {user_id} not found"))?;
        Ok(format!("To: {email}\nSubject: Your cart is waiting"))
    }
}

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

    struct FakeRepo(HashMap<u32, String>);

    impl UserRepo for FakeRepo {
        fn find_email(&self, user_id: u32) -> Option<String> {
            self.0.get(&user_id).cloned()
        }
    }

    fn notifier() -> Notifier<FakeRepo> {
        Notifier::new(FakeRepo(HashMap::from([(1, String::from("ana@example.com"))])))
    }

    #[test]
    fn builds_reminder_for_known_user() {
        let msg = notifier().reminder(1).unwrap();
        assert!(msg.starts_with("To: ana@example.com"));
    }

    #[test]
    fn fails_for_unknown_user() {
        assert_eq!(notifier().reminder(9).unwrap_err(), "user 9 not found");
    }
}
Output
running 2 tests
test tests::builds_reminder_for_known_user ... ok
test tests::fails_for_unknown_user ... ok

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

17. Spies: recording calls

To check how a dependency was used, let the fake record its calls. Trait methods usually take &self, so the recording goes through a RefCell: a container whose borrow_mut() lets you change its contents through a shared & reference. Cell & RefCell explains it in detail later; here it's just a list the fake can push to.

Rustcargo test
use std::cell::RefCell;

pub trait Mailer {
    fn send(&self, to: &str, body: &str);
}

pub fn notify_overdue(mailer: &impl Mailer, invoices: &[(&str, u32)]) -> usize {
    let mut sent = 0;
    for (customer, days) in invoices {
        if *days > 30 {
            mailer.send(customer, &format!("Invoice overdue by {days} days"));
            sent += 1;
        }
    }
    sent
}

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

    #[derive(Default)]
    struct SpyMailer {
        sent: RefCell<Vec<(String, String)>>,
    }

    impl Mailer for SpyMailer {
        fn send(&self, to: &str, body: &str) {
            self.sent.borrow_mut().push((to.to_string(), body.to_string()));
        }
    }

    #[test]
    fn emails_only_overdue_customers() {
        let spy = SpyMailer::default();
        let count = notify_overdue(&spy, &[("ana", 45), ("ben", 10), ("cy", 31)]);

        assert_eq!(count, 2);
        let sent = spy.sent.borrow();
        let recipients: Vec<&str> = sent.iter().map(|(to, _)| to.as_str()).collect();
        assert_eq!(recipients, ["ana", "cy"]);
        assert_eq!(sent[0].1, "Invoice overdue by 45 days");
    }
}
Output
running 1 test
test tests::emails_only_overdue_customers ... ok

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

For large interfaces, the mockall crate generates mocks from a trait with #[automock], including expect_*().times(1).returning(...).

18. Injecting time

Code that calls "now" directly is hard to test. Pass in a clock (a trait, or just a value) so tests can pick the time.

Rustcargo test
pub trait Clock {
    fn now_secs(&self) -> u64;
}

pub struct Token {
    pub expires_at: u64,
}

pub fn is_valid(token: &Token, clock: &impl Clock) -> bool {
    clock.now_secs() < token.expires_at
}

pub fn refresh_needed(token: &Token, clock: &impl Clock) -> bool {
    token.expires_at.saturating_sub(clock.now_secs()) < 300 // under 5 minutes left
}

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

    struct FixedClock(u64);

    impl Clock for FixedClock {
        fn now_secs(&self) -> u64 {
            self.0
        }
    }

    const TOKEN: Token = Token { expires_at: 10_000 };

    #[test]
    fn valid_before_expiry() {
        assert!(is_valid(&TOKEN, &FixedClock(9_999)));
        assert!(!is_valid(&TOKEN, &FixedClock(10_000)));
    }

    #[test]
    fn refresh_in_last_five_minutes() {
        assert!(!refresh_needed(&TOKEN, &FixedClock(9_000)));
        assert!(refresh_needed(&TOKEN, &FixedClock(9_800)));
        assert!(refresh_needed(&TOKEN, &FixedClock(20_000)));
    }
}
Output
running 2 tests
test tests::refresh_in_last_five_minutes ... ok
test tests::valid_before_expiry ... ok

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

The production Clock would use std::time::SystemTime::now().

19. Deterministic randomness

The same idea for randomness: accept a source of random numbers so tests can use a fixed sequence. Here a closure is enough; no trait needed.

Rustcargo test
/// Picks a server, skipping unhealthy ones. `rand_index(n)` returns a number in 0..n.
pub fn pick_server<'a>(servers: &[(&'a str, bool)], mut rand_index: impl FnMut(usize) -> usize) -> Option<&'a str> {
    let healthy: Vec<&str> = servers.iter().filter(|(_, ok)| *ok).map(|(name, _)| *name).collect();
    if healthy.is_empty() {
        return None;
    }
    Some(healthy[rand_index(healthy.len())])
}

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

    const SERVERS: [(&str, bool); 3] = [("a", true), ("b", false), ("c", true)];

    #[test]
    fn never_picks_unhealthy() {
        // Try every possible "random" choice.
        for i in 0..2 {
            let picked = pick_server(&SERVERS, |_| i).unwrap();
            assert_ne!(picked, "b");
        }
    }

    #[test]
    fn uses_the_random_index() {
        let mut calls = vec![];
        let picked = pick_server(&SERVERS, |n| {
            calls.push(n);
            1
        });
        assert_eq!(picked, Some("c"));
        assert_eq!(calls, [2]); // asked for a number below 2 (two healthy servers)
    }

    #[test]
    fn none_when_all_down() {
        assert_eq!(pick_server(&[("a", false)], |_| 0), None);
    }
}
Output
running 3 tests
test tests::never_picks_unhealthy ... ok
test tests::none_when_all_down ... ok
test tests::uses_the_random_index ... ok

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

20. Testing output by writing to impl Write

Code that prints straight to stdout is hard to check. Write to any std::io::Write instead: pass std::io::stdout() in production and a Vec<u8> in tests.

Rustcargo test
use std::io::{self, Write};

pub fn print_table(out: &mut impl Write, rows: &[(&str, u32)]) -> io::Result<()> {
    let width = rows.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
    for (name, qty) in rows {
        writeln!(out, "{name:<width$} | {qty:>3}")?;
    }
    Ok(())
}

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

    #[test]
    fn aligns_columns() {
        let mut buf = Vec::new();
        print_table(&mut buf, &[("apples", 3), ("kiwi", 12)]).unwrap();
        let text = String::from_utf8(buf).unwrap();
        assert_eq!(text, "apples |   3\nkiwi   |  12\n");
    }

    #[test]
    fn empty_table_prints_nothing() {
        let mut buf = Vec::new();
        print_table(&mut buf, &[]).unwrap();
        assert!(buf.is_empty());
    }
}
Output
running 2 tests
test tests::aligns_columns ... ok
test tests::empty_table_prints_nothing ... ok

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

21. Tests run in parallel

By default cargo test runs tests on several threads at once. Tests that share global state (a file, an environment variable, a static) can interfere with each other. Give each test its own resources, or protect shared ones.

Rustcargo test
use std::sync::atomic::{AtomicU32, Ordering};

static NEXT_ID: AtomicU32 = AtomicU32::new(1);

pub fn next_id() -> u32 {
    NEXT_ID.fetch_add(1, Ordering::Relaxed)
}

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

    // Fragile: `assert_eq!(next_id(), 1)` would depend on which test runs first.
    // Robust: only assert things that hold whatever other tests do.
    #[test]
    fn ids_increase() {
        let a = next_id();
        let b = next_id();
        assert!(b > a);
    }

    #[test]
    fn ids_are_unique_across_threads() {
        let handles: Vec<_> = (0..4).map(|_| std::thread::spawn(|| (0..100).map(|_| next_id()).collect::<Vec<_>>())).collect();
        let mut all: Vec<u32> = handles.into_iter().flat_map(|h| h.join().unwrap()).collect();
        all.sort();
        all.dedup();
        assert_eq!(all.len(), 400);
    }
}
Output
running 2 tests
test tests::ids_are_unique_across_threads ... ok
test tests::ids_increase ... ok

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

If tests truly must not overlap (they share a database, say), a static LOCK: Mutex<()> that each test locks is the simplest fix; cargo test -- --test-threads=1 is the blunt one. The Threads lesson covers spawn and join.

22. Integration tests: the tests/ directory

Unit tests sit next to the code. Integration tests live in tests/ at the package root. Each file there is compiled as a separate crate that depends on your library, so it can only use the public API, exactly like a real user.

Text
invoicer/
├── src/
│   └── lib.rs
└── tests/
    ├── checkout.rs         one test crate
    ├── refunds.rs          another test crate
    └── common/
        └── mod.rs          shared helpers (not a test crate itself)
Rust
// tests/common/mod.rs
use invoicer::Cart;

pub fn cart_with(items: &[(&str, u32)]) -> Cart {
    let mut cart = Cart::new();
    for (name, cents) in items {
        cart.add(name, *cents);
    }
    cart
}
Rust
// tests/checkout.rs
mod common; // pulls in tests/common/mod.rs

use invoicer::checkout;

#[test]
fn checkout_totals_the_cart() {
    let cart = common::cart_with(&[("book", 2000), ("pen", 500)]);
    let receipt = checkout(&cart).unwrap();
    assert_eq!(receipt.total_cents, 2500);
}

Only library crates can have integration tests, which is one more reason to keep main.rs thin and put the logic in lib.rs.

Kind Rust
unit test #[cfg(test)] mod tests in the same file
integration test a file in tests/
shared test utilities tests/common/mod.rs, or a dev-dependency crate in the workspace

23. Doc tests

The previous lesson showed that code blocks in /// comments run as tests. They double as usage examples, so they're the natural place to test the "happy path" of a public API.

Rustcargo test (doc tests)
/// A length in meters.
///
/// ```
/// use units::Meters;
///
/// let run = Meters::from_km(5.0) + Meters(250.0);
/// assert_eq!(run.0, 5250.0);
/// assert_eq!(run.to_string(), "5.25 km");
/// ```
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Meters(pub f64);

impl Meters {
    /// ```
    /// assert_eq!(units::Meters::from_km(1.5), units::Meters(1500.0));
    /// ```
    pub fn from_km(km: f64) -> Meters {
        Meters(km * 1000.0)
    }
}

impl std::ops::Add for Meters {
    type Output = Meters;
    fn add(self, other: Meters) -> Meters {
        Meters(self.0 + other.0)
    }
}

impl std::fmt::Display for Meters {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        if self.0 >= 1000.0 {
            write!(f, "{} km", self.0 / 1000.0)
        } else {
            write!(f, "{} m", self.0)
        }
    }
}
Output
running 2 tests
test src/lib.rs - Meters (line 3) ... ok
test src/lib.rs - Meters::from_km (line 14) ... ok

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

24. Property-style testing

Example-based tests check the cases you thought of. Property-based tests check a rule against many generated inputs: "sorting twice is the same as sorting once", "decode(encode(x)) == x". Here is the idea by hand, with a tiny deterministic generator:

Rustcargo test
pub fn run_length_encode(s: &str) -> Vec<(char, usize)> {
    let mut out: Vec<(char, usize)> = Vec::new();
    for c in s.chars() {
        match out.last_mut() {
            Some((last, n)) if *last == c => *n += 1,
            _ => out.push((c, 1)),
        }
    }
    out
}

pub fn run_length_decode(runs: &[(char, usize)]) -> String {
    runs.iter().map(|&(c, n)| c.to_string().repeat(n)).collect()
}

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

    // A small linear congruential generator: same seed, same "random" strings.
    fn random_strings(count: usize) -> Vec<String> {
        let mut state: u64 = 42;
        let mut next = move || {
            state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
            (state >> 33) as usize
        };
        (0..count)
            .map(|_| {
                let len = next() % 20;
                (0..len).map(|_| ['a', 'b', 'c'][next() % 3]).collect()
            })
            .collect()
    }

    #[test]
    fn decode_inverts_encode() {
        for s in random_strings(500) {
            assert_eq!(run_length_decode(&run_length_encode(&s)), s, "input {s:?}");
        }
    }

    #[test]
    fn neighbouring_runs_differ() {
        for s in random_strings(500) {
            let runs = run_length_encode(&s);
            assert!(runs.windows(2).all(|w| w[0].0 != w[1].0), "input {s:?} gave {runs:?}");
        }
    }
}
Output
running 2 tests
test tests::decode_inverts_encode ... ok
test tests::neighbouring_runs_differ ... ok

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

The proptest crate does this properly: it generates inputs from strategies and, when a test fails, shrinks the input to the smallest failing case.

Rust
use proptest::prelude::*;

proptest! {
    #[test]
    fn decode_inverts_encode(s in "[abc]{0,20}") {
        prop_assert_eq!(run_length_decode(&run_length_encode(&s)), s);
    }
}

25. Benchmarks

Benchmarks need optimized builds and statistics. The standard choice is the criterion crate, placed in benches/.

TOMLCargo.toml
[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "parsing"
harness = false            # criterion provides its own main
Rust
// benches/parsing.rs
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;

fn bench_parse(c: &mut Criterion) {
    c.bench_function("parse 1k lines", |b| {
        let input = "key=value\n".repeat(1000);
        b.iter(|| invoicer::parse_config(black_box(&input)))
    });
}

criterion_group!(benches, bench_parse);
criterion_main!(benches);
Terminal
cargo bench                          # runs benches/ with the release profile

black_box stops the optimizer from deleting work whose result is unused. For a rough measurement without a crate, time a loop in a --release build:

Rust
use std::hint::black_box;
use std::time::Instant;

fn sum_of_squares(n: u64) -> u64 {
    (1..=n).map(|x| x * x).sum()
}

fn main() {
    let start = Instant::now();
    let mut total = 0;
    for _ in 0..1_000 {
        total += sum_of_squares(black_box(10_000));
    }
    println!("result {total} in {:?}", start.elapsed());
}

26. Realistic: testing a shopping cart

A small module with a thorough test suite: happy paths, edge cases, errors, and an invariant check.

Rustcargo test
use std::collections::BTreeMap;

#[derive(Debug, PartialEq)]
pub enum CartError {
    UnknownProduct(String),
    ZeroQuantity,
    OutOfStock { product: String, available: u32 },
}

pub struct Cart<'a> {
    catalog: &'a BTreeMap<&'a str, (u32, u32)>, // name -> (price in cents, stock)
    lines: BTreeMap<String, u32>,
}

impl<'a> Cart<'a> {
    pub fn new(catalog: &'a BTreeMap<&'a str, (u32, u32)>) -> Self {
        Cart { catalog, lines: BTreeMap::new() }
    }

    pub fn add(&mut self, product: &str, qty: u32) -> Result<(), CartError> {
        if qty == 0 {
            return Err(CartError::ZeroQuantity);
        }
        let &(_, stock) = self.catalog.get(product).ok_or_else(|| CartError::UnknownProduct(product.to_string()))?;
        let current = self.lines.get(product).copied().unwrap_or(0);
        if current + qty > stock {
            return Err(CartError::OutOfStock { product: product.to_string(), available: stock - current });
        }
        self.lines.insert(product.to_string(), current + qty);
        Ok(())
    }

    pub fn remove(&mut self, product: &str) -> bool {
        self.lines.remove(product).is_some()
    }

    pub fn total_cents(&self) -> u32 {
        self.lines.iter().map(|(name, qty)| self.catalog[name.as_str()].0 * qty).sum()
    }

    pub fn item_count(&self) -> u32 {
        self.lines.values().sum()
    }
}

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

    fn catalog() -> BTreeMap<&'static str, (u32, u32)> {
        BTreeMap::from([("book", (1500, 3)), ("pen", (200, 100))])
    }

    #[test]
    fn empty_cart_totals_zero() {
        let cat = catalog();
        let cart = Cart::new(&cat);
        assert_eq!((cart.total_cents(), cart.item_count()), (0, 0));
    }

    #[test]
    fn adding_accumulates_quantity() {
        let cat = catalog();
        let mut cart = Cart::new(&cat);
        cart.add("pen", 2).unwrap();
        cart.add("pen", 3).unwrap();
        cart.add("book", 1).unwrap();
        assert_eq!(cart.item_count(), 6);
        assert_eq!(cart.total_cents(), 5 * 200 + 1500);
    }

    #[test]
    fn rejects_unknown_and_zero() {
        let cat = catalog();
        let mut cart = Cart::new(&cat);
        assert_eq!(cart.add("laptop", 1), Err(CartError::UnknownProduct(String::from("laptop"))));
        assert_eq!(cart.add("pen", 0), Err(CartError::ZeroQuantity));
    }

    #[test]
    fn stock_limit_counts_what_is_already_in_cart() {
        let cat = catalog();
        let mut cart = Cart::new(&cat);
        cart.add("book", 2).unwrap();
        let err = cart.add("book", 2).unwrap_err();
        assert_eq!(err, CartError::OutOfStock { product: String::from("book"), available: 1 });
        assert_eq!(cart.item_count(), 2, "a failed add must not change the cart");
    }

    #[test]
    fn remove_reports_whether_anything_was_removed() {
        let cat = catalog();
        let mut cart = Cart::new(&cat);
        cart.add("pen", 1).unwrap();
        assert!(cart.remove("pen"));
        assert!(!cart.remove("pen"));
        assert_eq!(cart.total_cents(), 0);
    }
}
Output
running 5 tests
test tests::adding_accumulates_quantity ... ok
test tests::empty_cart_totals_zero ... ok
test tests::rejects_unknown_and_zero ... ok
test tests::remove_reports_whether_anything_was_removed ... ok
test tests::stock_limit_counts_what_is_already_in_cart ... ok

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

27. Realistic: testing a parser's error messages

Good error messages are part of a parser's behavior, so they deserve tests. Table-driven tests keep the many failure cases readable.

Rustcargo test
#[derive(Debug, PartialEq)]
pub struct Rgb(pub u8, pub u8, pub u8);

pub fn parse_color(s: &str) -> Result<Rgb, String> {
    let hex = s.strip_prefix('#').ok_or_else(|| format!("{s:?}: must start with '#'"))?;
    let hex = match hex.len() {
        6 => hex.to_string(),
        3 => hex.chars().flat_map(|c| [c, c]).collect(), // #abc -> #aabbcc
        n => return Err(format!("{s:?}: expected 3 or 6 hex digits, found {n}")),
    };
    let channel = |i: usize| {
        u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| format!("{s:?}: invalid hex {:?}", &hex[i..i + 2]))
    };
    Ok(Rgb(channel(0)?, channel(2)?, channel(4)?))
}

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

    #[test]
    fn valid_colors() {
        for (input, expected) in [
            ("#ff8000", Rgb(255, 128, 0)),
            ("#000000", Rgb(0, 0, 0)),
            ("#FFF", Rgb(255, 255, 255)),
            ("#1a2", Rgb(0x11, 0xaa, 0x22)),
        ] {
            assert_eq!(parse_color(input), Ok(expected), "input {input:?}");
        }
    }

    #[test]
    fn error_messages() {
        for (input, message) in [
            ("ff8000", r#""ff8000": must start with '#'"#),
            ("#ff80", r##""#ff80": expected 3 or 6 hex digits, found 4"##),
            ("#gg0000", r##""#gg0000": invalid hex "gg""##),
            ("#12345z", r##""#12345z": invalid hex "5z""##),
        ] {
            assert_eq!(parse_color(input).unwrap_err(), message, "input {input:?}");
        }
    }
}
Output
running 2 tests
test tests::error_messages ... ok
test tests::valid_colors ... ok

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

28. Realistic: a service tested with fakes end to end

A signup service with two dependencies, a user store and a mailer, tested with an in-memory store and a spy mailer. This is the shape most business-logic tests take in Rust.

Rustcargo test
use std::cell::RefCell;
use std::collections::HashSet;

pub trait UserStore {
    fn exists(&self, email: &str) -> bool;
    fn insert(&mut self, email: &str) -> u32;
}

pub trait Mailer {
    fn send_welcome(&self, email: &str);
}

pub struct SignupService<S: UserStore, M: Mailer> {
    pub store: S,
    pub mailer: M,
}

impl<S: UserStore, M: Mailer> SignupService<S, M> {
    pub fn signup(&mut self, raw_email: &str) -> Result<u32, String> {
        let email = raw_email.trim().to_lowercase();
        if !email.contains('@') {
            return Err(format!("invalid email {raw_email:?}"));
        }
        if self.store.exists(&email) {
            return Err(format!("{email} is already registered"));
        }
        let id = self.store.insert(&email);
        self.mailer.send_welcome(&email);
        Ok(id)
    }
}

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

    #[derive(Default)]
    struct MemoryStore {
        emails: HashSet<String>,
    }

    impl UserStore for MemoryStore {
        fn exists(&self, email: &str) -> bool {
            self.emails.contains(email)
        }
        fn insert(&mut self, email: &str) -> u32 {
            self.emails.insert(email.to_string());
            self.emails.len() as u32
        }
    }

    #[derive(Default)]
    struct SpyMailer {
        welcomed: RefCell<Vec<String>>,
    }

    impl Mailer for SpyMailer {
        fn send_welcome(&self, email: &str) {
            self.welcomed.borrow_mut().push(email.to_string());
        }
    }

    fn service() -> SignupService<MemoryStore, SpyMailer> {
        SignupService { store: MemoryStore::default(), mailer: SpyMailer::default() }
    }

    #[test]
    fn new_user_is_stored_and_welcomed() {
        let mut svc = service();
        assert_eq!(svc.signup(" Ana@Example.com "), Ok(1));
        assert!(svc.store.exists("ana@example.com"));
        assert_eq!(*svc.mailer.welcomed.borrow(), ["ana@example.com"]);
    }

    #[test]
    fn duplicate_is_rejected_without_email() {
        let mut svc = service();
        svc.signup("ana@example.com").unwrap();
        let err = svc.signup("ANA@example.com").unwrap_err();
        assert_eq!(err, "ana@example.com is already registered");
        assert_eq!(svc.mailer.welcomed.borrow().len(), 1, "no second welcome mail");
    }

    #[test]
    fn invalid_email_touches_nothing() {
        let mut svc = service();
        assert!(svc.signup("not-an-email").is_err());
        assert!(svc.store.emails.is_empty());
        assert!(svc.mailer.welcomed.borrow().is_empty());
    }
}
Output
running 3 tests
test tests::duplicate_is_rejected_without_email ... ok
test tests::invalid_email_touches_nothing ... ok
test tests::new_user_is_stored_and_welcomed ... ok

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

29. Testing a whole program's behavior

Tests can also check a binary end to end by running it with std::process::Command. Cargo sets CARGO_BIN_EXE_<name> for integration tests so they can find the built binary.

Rust
// tests/cli.rs
use std::process::Command;

#[test]
fn prints_usage_without_arguments() {
    let output = Command::new(env!("CARGO_BIN_EXE_invoicer")).output().unwrap();
    assert!(!output.status.success());
    assert!(String::from_utf8_lossy(&output.stderr).contains("usage: invoicer <file>"));
}

Crates like assert_cmd and insta (snapshot testing) make these tests shorter.

30. What cargo test runs, all together

Terminal
$ cargo test
   Compiling invoicer v0.3.1
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.52s
     Running unittests src/lib.rs (target/debug/deps/invoicer-3f2a...)
running 12 tests
...
     Running tests/checkout.rs (target/debug/deps/checkout-9c1e...)
running 3 tests
...
   Doc-tests invoicer
running 4 tests
...

One command runs three kinds of tests: unit tests from src/, each integration test crate in tests/, and doc tests. There's no plugin to configure and no separate test runner, which makes cargo test the standard step in every Rust CI pipeline, usually alongside cargo clippy -- -D warnings and cargo fmt --check.

Summary

  • A #[test] function passes if it returns and fails if it panics. Put unit tests in #[cfg(test)] mod tests with use super::*;, which can see private items.
  • Use assert!, assert_eq!(actual, expected), assert_ne!, and matches!, with a message that identifies the case.
  • Use #[should_panic(expected = "...")] for intended panics; return Result from tests to use ?; assert on unwrap_err() for error paths.
  • Fixtures are plain helper functions (..fixture() for variations); teardown is a Drop guard; parameterized tests are loops over tables.
  • Mock with traits: fakes for behavior, spies with RefCell to record calls, injected clocks and random sources.
  • Integration tests in tests/ use only the public API; doc tests keep examples honest; proptest and criterion cover property tests and benchmarks.
  • Tests run in parallel, so avoid shared mutable state.

This completes the Modules, Crates & Testing section. Next: Custom Error Types, the start of Error Handling.