RustArena

Step 6 of 6

Organizing tests

As a project grows, so does its test suite. Rust has three kinds of tests, and cargo test runs all of them.

Unit tests: next to the code

Everything you've written so far: a #[cfg(test)] mod tests at the bottom of each file, testing that file's functions, including private ones. They're fast and precise. Put shared setup in ordinary helper functions inside the module (no #[test] on them):

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

    fn stocked() -> Inventory {      // a helper, not a test
        let mut inv = Inventory::new();
        inv.add("apple", 5);
        inv
    }

    #[test]
    fn removing_some_leaves_the_rest() {
        let mut inv = stocked();
        assert_eq!(inv.remove("apple", 2), Ok(3));
    }
}

Integration tests: the tests/ folder

Files in a tests/ folder next to src/ are integration tests. Each file is compiled as a separate crate that uses your library like any other user would, so it can only reach your pub items:

calendar/
├── Cargo.toml
├── src/
│   └── lib.rs          pub fn is_leap_year(...), with unit tests inside
└── tests/
    ├── leap_years.rs   integration tests
    └── common/
        └── mod.rs      helpers shared by several test files
// tests/leap_years.rs
use calendar::is_leap_year;

#[test]
fn year_2000_is_a_leap_year() {
    assert!(is_leap_year(2000));
}

No #[cfg(test)] needed there: the whole folder only exists for tests. Integration tests need a library crate (src/lib.rs); a binary-only project usually moves its logic into lib.rs and keeps main.rs small, partly so it can be tested this way.

Doc tests: examples that can't go stale

Code blocks in documentation comments (///, covered in the tooling module) are compiled and run by cargo test too. An example in the docs that no longer works fails the build:

/// Returns true for leap years.
///
/// ```
/// assert!(calendar::is_leap_year(2024));
/// assert!(!calendar::is_leap_year(1900));
/// ```
pub fn is_leap_year(year: u32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
$ cargo test
     Running unittests src/lib.rs (target/debug/deps/calendar-02d0b668d0f176bb)
running 2 tests
test tests::century_is_not_leap ... ok
test tests::divisible_by_four_is_leap ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running tests/leap_years.rs (target/debug/deps/leap_years-6feefd48868830b4)
running 1 test
test year_2000_is_a_leap_year ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests calendar
running 1 test
test src/lib.rs - is_leap_year (line 3) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

(On this site everything lives in one file, so exercises use unit tests; integration and doc tests need a real Cargo project.)

Running some of the tests

Command Runs
cargo test leap only tests whose name contains leap
cargo test --test leap_years one integration test file
cargo test --doc only doc tests
cargo test -- --nocapture shows println! output from passing tests (hidden by default)
cargo test -- --ignored the #[ignore] tests

Table-driven tests

When many inputs follow the same rule, list them as data and loop. Adding a case is one line, and the message says which case failed:

fn wind_label(kmh: u32) -> &'static str {
    match kmh {
        0..=19 => "calm",
        20..=49 => "breezy",
        50..=88 => "gale",
        _ => "storm",
    }
}

fn main() {
    let cases = [(0, "calm"), (19, "calm"), (20, "breezy"), (49, "breezy"), (50, "gale"), (89, "storm")];
    for (kmh, expected) in cases {
        assert_eq!(wind_label(kmh), expected, "wind_label({kmh})");
    }
    println!("{} cases passed", cases.len());
}
6 cases passed

Common mistakes

Your task

Write one table-driven test for classify_temp: an array of (celsius, expected) pairs and a for (celsius, expected) in ... loop with an assert_eq! that has a message. Choose the cases so every planted boundary bug is caught: 0 and below is freezing, 1 to 14 cold, 15 to 24 mild, 25 and up hot.

Challenge 2

Write tests for Inventory. First write a helper fn stocked() -> Inventory (not a test) in the tests module that returns an inventory with 5 apples and 2 pears. Then write at least 3 tests that start from stocked().

The rules: add increases the count, remove fails with Err if the item is unknown or there aren't enough, and otherwise returns Ok with the amount left; count is 0 for unknown items.

Challenge 3

A sensor keeps its last few readings in a RingBuffer: it holds at most capacity items, and pushing into a full buffer throws away the oldest reading. Write at least 3 tests for push, latest, oldest and len. Write the tests module yourself.

Previous: Fixing bugs test-first