Why this matters
Writing a test is easy; writing tests that actually catch bugs takes some technique. A test suite that passes no matter what the code does is worse than none, because it gives false confidence. This lesson covers the tools for testing failure paths (panics and errors), patterns that keep tests short and thorough (table-driven tests, helpers), and a way to check that your tests are any good: break the code on purpose and see whether they notice.
Testing that code panics
Some functions are supposed to panic on misuse. #[should_panic] makes a test pass only if its body panics:
fn checked_index(v: &[i32], i: usize) -> i32 {
if i >= v.len() {
panic!("index {i} out of range for length {}", v.len());
}
v[i]
}
fn main() {
println!("{}", checked_index(&[1, 2, 3], 1));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "out of range")]
fn rejects_bad_index() {
checked_index(&[1, 2, 3], 3);
}
}
Always include expected = "...": the test then passes only if the panic message contains that text. Without it, any panic counts, including one caused by a bug in the test itself.
Testing errors: tests that return Result
For functions returning Result, assert on the Err directly, or let the test return a Result and use ?:
fn parse_port(s: &str) -> Result<u16, String> {
let n: u32 = s.parse().map_err(|_| format!("{s:?} is not a number"))?;
if n == 0 || n > 65535 {
return Err(format!("{n} is out of range"));
}
Ok(n as u16)
}
fn main() {
println!("{:?}", parse_port("8080"));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid() -> Result<(), String> {
assert_eq!(parse_port("80")?, 80);
assert_eq!(parse_port("65535")?, 65535);
Ok(())
}
#[test]
fn out_of_range() {
assert_eq!(parse_port("0"), Err(String::from("0 is out of range")));
assert!(parse_port("70000").is_err());
}
#[test]
fn not_a_number() {
let err = parse_port("http").unwrap_err();
assert!(err.contains("not a number"), "unexpected message: {err}");
}
}
A test returning Result<(), E> fails if it returns Err, which lets you use ? in test code. Test the error paths as carefully as the happy path: that is where real-world bugs hide.
Table-driven tests
When many inputs exercise the same logic, put them in a table and loop:
#[test]
fn roman_numerals() {
let cases = [(1, "I"), (4, "IV"), (9, "IX"), (14, "XIV"), (1994, "MCMXCIV")];
for (n, expected) in cases {
assert_eq!(to_roman(n), expected, "to_roman({n})");
}
}
The message argument ("to_roman({n})") matters: without it, a failure tells you two strings differed but not which case. Tables make it cheap to add the edge case you just thought of.
Choosing test cases
For each function, think through the categories:
- Typical values: one or two ordinary cases.
- Boundaries: 0, 1, the last valid value and the first invalid one (capacity reached, 65535 and 65536).
- Empty and minimal inputs: empty string, empty slice, a single element.
- Extremes:
i32::MIN,u64::MAX, very long input. - Invalid input: wrong format, wrong characters, missing parts.
- Unicode: when text is involved, at least one multi-byte character.
Boundaries deserve special attention: off-by-one errors (> versus >=) are the most common bug, and only a test exactly at the boundary catches them.
Are the tests any good? Break the code
A simple, powerful check: change the implementation in a small, wrong way (flip a > to >=, return a constant, delete a branch) and run the tests. If they still pass, they are not testing that behavior. Tools automate this as mutation testing (cargo-mutants in the wider ecosystem); the checkers for this lesson's exercises do exactly that with a handful of hand-made mutants.
Test helpers and organization
Tests are ordinary Rust code: factor repeated setup into helper functions inside the tests module (they need no #[test]). A few other tools you will meet in cargo projects:
- Integration tests: files in a
tests/directory next tosrc/; each is compiled as a separate crate that can use only your library's public API.cargo testruns them too. - Doc tests: code examples in
///documentation comments are compiled and run bycargo test, so examples in docs never go stale. - Test-driven development: write a failing test first, then the code to make it pass, then clean up. The first exercise works this way: the tests are the specification.
Common mistakes
#[should_panic]withoutexpected: passes on any panic, including bugs in the test.- Asserting only the happy path: write at least one test per error case.
- One giant test: when it fails you do not know which behavior broke. One behavior per test, or a table with per-case messages.
- Tests depending on each other or on order: tests run in parallel threads, in any order. Each must set up its own data.
- Testing implementation details that may legitimately change, instead of behavior.
Recap
#[should_panic(expected = "...")]tests intended panics; always give the expected text.- Tests may return
Result<(), E>and use?; assert onErrvalues to test error paths. - Table-driven tests with per-case messages cover many inputs cheaply.
- Pick cases by category: typical, boundaries, empty, extremes, invalid, Unicode.
- Check your tests by breaking the code: good tests fail.
Going further: read the Rust Book chapter 11.2 and 11.3 ("Controlling How Tests Are Run", "Test Organization"), and try writing a doc test in a cargo library project with cargo test --doc.