The compiler checks that your program follows Rust's type and ownership rules. It cannot check whether your pricing formula uses the right percentage or whether an empty input should produce zero. Tests describe those behavioral expectations and make them repeatable.
This lesson builds a complete library for estimating reading time. The example is deliberately small enough to understand, but includes invalid input, rounding, maximum integer values, a public API, and executable documentation. It prepares you for the CLI capstone.
Three kinds of executable checks
| Kind | Typical location | What it can access |
|---|---|---|
| Unit test | A #[cfg(test)] mod tests beside the implementation | Public items and accessible private implementation details |
| Integration test | A file under tests/ | The library's public API, as a separate crate |
| Documentation test | A Rust code block inside an item's documentation | The API available to a reader using the crate |
An assertion that is false panics; the test harness records that test as failed and normally continues with other tests. assert! checks a boolean, assert_eq! compares values and shows both when they differ, and assert_ne! checks inequality. Values compared by assert_eq! must support equality and debug formatting.
The macros are language tooling, not special return values: the exclamation mark marks a macro invocation. #[test] is an attribute that registers a function with the test harness. See the Book on writing tests.
Define behavior before implementation
Our function accepts a word count and a reading rate in words per minute. It returns whole minutes, rounded up:
- A zero rate is an error, even if the word count is zero.
- Zero words at a valid rate takes zero minutes.
- Any positive partial minute rounds up.
- The calculation must work for every
u32word count without overflowing.
Create the library:
cargo new reading-time --lib --edition 2024
cd reading-time
The hyphenated package name becomes reading_time in Rust import paths. Replace all of src/lib.rs with:
use std::error::Error;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ZeroRate;
impl fmt::Display for ZeroRate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("reading rate must be positive")
}
}
impl Error for ZeroRate {}
/// Estimate whole minutes, rounding a partial minute upward.
///
/// # Examples
///
/// ```
/// use reading_time::{reading_minutes, ZeroRate};
///
/// assert_eq!(reading_minutes(401, 200), Ok(3));
/// assert_eq!(reading_minutes(0, 200), Ok(0));
/// assert_eq!(reading_minutes(10, 0), Err(ZeroRate));
/// ```
pub fn reading_minutes(words: u32, words_per_minute: u32) -> Result<u32, ZeroRate> {
if words_per_minute == 0 {
return Err(ZeroRate);
}
Ok(words.div_ceil(words_per_minute))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounds_only_partial_minutes_up() {
let cases = [(0, 0), (1, 1), (200, 1), (201, 2), (400, 2)];
for (words, expected) in cases {
assert_eq!(
reading_minutes(words, 200),
Ok(expected),
"word count: {words}",
);
}
}
#[test]
fn rejects_zero_rate_including_empty_text() {
assert_eq!(reading_minutes(12, 0), Err(ZeroRate));
assert_eq!(reading_minutes(0, 0), Err(ZeroRate));
}
#[test]
fn handles_the_maximum_word_count() {
assert_eq!(reading_minutes(u32::MAX, 1), Ok(u32::MAX));
assert_eq!(reading_minutes(u32::MAX, 2), Ok(2_147_483_648));
}
}
Run:
cargo test
You should see three passing unit tests and one passing documentation test. Test order and elapsed time are not fixed.
The tempting expression (words + rate - 1) / rate can overflow before dividing. div_ceil handles the rounding without that intermediate overflow; checking the rate first prevents division by zero. Tests at both 0 and u32::MAX express our contract rather than merely checking an ordinary input. See u32::div_ceil.
Test the library from outside
Create a tests directory and put this complete file in tests/public_api.rs:
use reading_time::{ZeroRate, reading_minutes};
#[test]
fn estimates_a_short_article() -> Result<(), ZeroRate> {
let minutes = reading_minutes(650, 200)?;
assert_eq!(minutes, 4);
Ok(())
}
#[test]
fn reports_a_useful_error_message() {
let error = reading_minutes(100, 0).expect_err("zero rate must fail");
assert_eq!(error.to_string(), "reading rate must be positive");
}
cargo test now runs three unit tests, two integration tests and one documentation test. An integration file is a separate crate, so it imports the library by name and cannot call a private helper just because the file is in the same repository.
A test may return Result<(), E> to use ? for setup or fallible calls. Returning Err fails the test. Do not use ? for an error that is itself the behavior under test: inspect it with assert_eq!, matches! or expect_err instead.
For larger suites, shared helpers can live in tests/common/mod.rs. A top-level tests/common.rs becomes an additional test crate, which may be surprising. The Book explains test organization.
Documentation is part of the API
The /// comments above the function become its documentation. Rustdoc extracts and executes the fenced Rust example, which guards against stale names and incorrect behavior in examples that users copy.
Use cargo test --doc to focus on documentation examples. Rustdoc usually supplies a wrapper main where needed. The example must import the public items it uses, just as a user would.
Rustdoc supports compile_fail for an example that must be rejected by the compiler, such as a borrowing violation; no_run checks compilation without executing; ignore skips the check and should have a specific reason. A compile_fail example can fail for an unintended reason, so keep it focused and explain the intended error. These are rustdoc behaviors, not promises that a website's MDX renderer will execute code fences. See documentation tests.
Run the right slice of the suite
From the library project:
cargo test
cargo test rounds_only_partial_minutes_up
cargo test --lib
cargo test --test public_api
cargo test --doc
cargo test -- --show-output
cargo test -- --test-threads=1
Arguments before -- configure Cargo. Arguments after it go to the test executable. A name filter normally matches substrings; use the harness's --exact with the full test name when exact selection matters.
Tests normally run concurrently and capture their output. --show-output displays successful captured output after tests; --nocapture allows live output that can interleave. --test-threads=1 serializes tests within the selected test executable; it does not coordinate separate processes or external programs.
#[ignore = "reason"] marks a test that runs only when explicitly requested, such as with cargo test -- --ignored. Avoid making network-dependent tests part of a deterministic local default suite. See controlling test execution.
Design tests around independent state
Tests that change the working directory, mutate shared environment state or all write output.txt can interfere with one another. Prefer passing configuration and I/O dependencies to functions rather than reading global state deep inside logic.
For example, a function accepting impl BufRead can receive a file in the application and a Cursor over known bytes in a test. A function accepting impl Write can write to standard output in the application and a Vec<u8> in a test. The capstone applies both ideas.
In the Rust 2024 edition, operations such as std::env::set_var are unsafe because modifying the process environment can race with other threads or foreign code. Do not add an unsafe block merely to make a test compile; pass the value in instead. See the edition guide on environment functions.
When files are essential, give each test its own directory, remove it through cleanup, and avoid assumptions about execution order. For time-dependent behavior, inject a clock or test state transitions rather than sleeping for a guessed duration. A timeout can stop a hung test but does not prove the operation correct.
Assert the behavior that matters
Start with examples at boundaries: empty input, one item, the exact threshold, just past it, invalid input and maximum values where relevant. Then ask whether each test can catch a plausible bug.
For the reading-time function, changing upward rounding to floor division should fail the partial-minute test. Accidentally accepting zero rate should fail the invalid-input test. An overflow-prone implementation should fail a maximum-value case even if ordinary articles work.
Use exact output assertions when formatting is the contract. Avoid asserting incidental allocation counts, private helper call sequences or nondeterministic map iteration order.
If panicking is the intended public contract, #[should_panic(expected = "message fragment")] can check it. It accepts any panic containing that fragment, so keep setup small enough that another panic cannot produce a false pass. Expected invalid user input is usually better represented by Result than panic.
Idioms and pitfalls
- A test function needs
#[test]; naming a functiontest_somethingalone does not register it. - Do not make tests depend on one another's mutations or execution order.
debug_assert!can be disabled in optimized builds. Use ordinary assertions for test expectations.- Do not silently discard I/O failures with
filter_map(Result::ok)when missing data changes the answer. - Unit tests for a binary do not automatically make its functions importable by integration tests. Put reusable logic in
src/lib.rs, and keepsrc/main.rsthin. - Passing tests are evidence for chosen cases, not proof that every input or unsafe invariant is correct.
Exercises
1. Specify minimal whole minutes
Add an integration test that checks many word counts and rates. For a positive result, its capacity must cover all words, while one fewer minute must not. Avoid overflow in the assertions.
2. Find a weak test
A suite only checks reading_minutes(400, 200) == Ok(2). Which incorrect implementation would still pass? Give two new examples that distinguish it from our contract.
3. Test an error rather than propagating it
Write a test that verifies the error type and exact display message for a zero rate. Explain why returning reading_minutes(50, 0)? from the test is the wrong assertion.
Solutions
1. Check the mathematical contract
Save this complete additional file as tests/invariants.rs and run cargo test:
use reading_time::reading_minutes;
#[test]
fn minutes_are_the_smallest_sufficient_whole_number() {
for rate in 1..=50_u32 {
for words in 0..=1_000_u32 {
let minutes = reading_minutes(words, rate).expect("positive rate");
let capacity = u64::from(minutes) * u64::from(rate);
assert!(capacity >= u64::from(words));
if minutes > 0 {
let previous = u64::from(minutes - 1) * u64::from(rate);
assert!(previous < u64::from(words));
}
}
}
}
This is a bounded exhaustive check, not randomized property testing or a proof over all u32 inputs. Widening to u64 keeps the assertion arithmetic safe. With this file, the project has seven test cases across unit, integration and documentation suites.
2. Include partial minutes and invalid rates
Floor division words / rate passes the original exact-minute example. reading_minutes(1, 200) == Ok(1) and reading_minutes(201, 200) == Ok(2) distinguish rounding behavior. Also test zero rate, empty input and large counts to cover the rest of the contract.
3. Inspect the expected failure
This complete integration test can be saved separately as tests/invalid_rate.rs:
use reading_time::{ZeroRate, reading_minutes};
#[test]
fn zero_rate_has_a_typed_error() {
let error = reading_minutes(50, 0).expect_err("zero rate is invalid");
assert_eq!(error, ZeroRate);
assert_eq!(error.to_string(), "reading rate must be positive");
}
Using ? would propagate the expected error and fail the test. Here the error is a successful observation of required behavior, so we inspect it.
Key takeaways
- Unit tests check local behavior, integration tests check the public API, and documentation tests keep examples executable.
- Test boundaries and plausible mistakes; separate expected error assertions from setup errors.
- Inject inputs and I/O so tests remain independent and deterministic.
- Run
cargo fmt --check,cargo clippy --all-targets -- -D warningsandcargo testas complementary checks. Formatting and lints do not replace behavioral tests.
Next lesson
Continue with Unsafe and FFI boundaries in Rust, where a test suite must accompany an explicit safety argument.

