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
- Integration tests reaching for private items:
error[E0603]: function `helper` is private. Test through the public API, or write a unit test instead. - Tests that depend on each other: tests run in parallel and in any order. Each test should set up its own data (that's what helpers are for).
- Debug
println!in tests that "doesn't show up": passing tests' output is captured. Use-- --nocapture.
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.