10 - Tests¶
What this session is¶
About 45 minutes. You'll learn how to write tests in Rust. Like nearly all Rust tooling, the test framework is built into the toolchain - cargo test and you're going. No frameworks to install.
The simplest test¶
In any module, mark a function #[test]:
Run:
You should see:
#[test] marks the function as a test. cargo test auto-discovers and runs them. assert_eq!(actual, expected) checks for equality and panics with a useful message on failure.
Convention: tests in a #[cfg(test)] module¶
Convention: put unit tests at the bottom of the same file as the code, wrapped in a tests module:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn is_even(n: i32) -> bool {
n % 2 == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn detects_even() {
assert!(is_even(4));
assert!(!is_even(7));
}
}
What's new:
- #[cfg(test)] - only compile this module when running tests. Keeps test code out of release builds.
- mod tests { ... } - a module named tests.
- use super::*; - bring all of the parent module's items into the test module's scope.
- assert!(condition) - passes if condition is true; panics otherwise.
This is the idiomatic shape. Real Rust libraries put unit tests right next to the code.
Useful assertions¶
assert!(cond)- true.assert_eq!(a, b)- equal.assert_ne!(a, b)- not equal.
With custom messages:
For tests on Result-returning functions:
#[test]
fn parse_works() -> Result<(), Box<dyn std::error::Error>> {
let n: i32 = "42".parse()?;
assert_eq!(n, 42);
Ok(())
}
If you return Result, you can use ? inside the test. Convenient.
Watching a test fail (do this)¶
In your project, change add to a - b. Run cargo test. You should see:
test tests::adds_two_numbers ... FAILED
failures:
---- tests::adds_two_numbers stdout ----
thread 'tests::adds_two_numbers' panicked at src/lib.rs:13:9:
assertion `left == right` failed
left: -1
right: 5
The failure tells you: what was actual (-1), what was expected (5), the line. Restore add; tests pass again.
Testing panics: #[should_panic]¶
For tests that the code should panic in:
expected = "..." requires the panic message to contain that substring. Useful for testing invariant checks.
Testing Results¶
For functions returning Result, two patterns:
#[test]
fn parse_succeeds() {
let r = "42".parse::<i32>();
assert!(r.is_ok());
assert_eq!(r.unwrap(), 42);
}
#[test]
fn parse_fails_on_garbage() {
assert!("hello".parse::<i32>().is_err());
}
Or the Result-returning test from above (cleaner when you want to chain ?s).
Parameterized tests: not built in¶
Unlike Java's @ParameterizedTest, Rust doesn't have built-in parameterization. Two common workarounds:
Manual loop:
#[test]
fn is_even_cases() {
let cases = [(0, true), (1, false), (2, true), (-4, true), (-7, false)];
for (n, expected) in cases {
assert_eq!(is_even(n), expected, "is_even({n}) was wrong");
}
}
Simple and clear. Common in real Rust.
The rstest crate provides #[rstest] and #[case(...)] if you want first-class parameterized tests. We'll meet crates in page 11.
Integration tests¶
Unit tests (above) live in the same file as the code they test. Integration tests live in a top-level tests/ directory and test the public API as an external user would:
tests/integration_test.rs:
cargo test runs both unit and integration tests.
Documentation tests (free, easy, wonderful)¶
Code samples in doc comments are also tests:
/// Returns the sum of two integers.
///
/// # Examples
///
/// ```
/// use my_project::add;
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
cargo test compiles and runs every code block in /// comments. Documentation stays accurate because it's tested. One of Rust's killer features.
Useful cargo test flags¶
| Flag | What it does |
|---|---|
cargo test |
Run all tests. |
cargo test -- --nocapture |
Show println! output from passing tests. |
cargo test foo |
Run only tests whose name contains "foo". |
cargo test -- --test-threads=1 |
Run sequentially (default is parallel). |
cargo test --release |
Run with optimizations on (slower compile, runtime matters for some tests). |
Cargo bench (briefly)¶
For performance benchmarks, the stable approach is the criterion crate (we'll mention crates in page 11). Don't try to use cargo bench on stable Rust - it's nightly-only currently.
Exercise¶
In your project:
-
Make
src/lib.rs(instead ofsrc/main.rs, or in addition - Cargo supports both): -
Update
Cargo.tomlif needed - change the[package]to have a library, or leave the binary structure. (Cargo auto-detectslib.rs.) -
Add a
#[cfg(test)] mod testsat the bottom oflib.rswith: - Parameterized test for
word_count(the manual-loop pattern):""→ 0,"hello"→ 1,"hello world"→ 2," many spaces here "→ 3. -
Parameterized test for
is_palindrome:""→ true,"a"→ true,"racecar"→ true,"hello"→ false,"Racecar"→ true. -
Run
cargo test. -
Break each function. Watch the test fail. Fix. Watch it pass.
Stretch: add a doctest above word_count showing how to use it. Run cargo test and confirm the doctest runs.
What you might wonder¶
"Why are tests in the same file as the code?"
Convention. Unit tests live in #[cfg(test)] mod tests at the bottom; integration tests go in tests/. This lets unit tests access private functions (they're in the same module). Integration tests can only see the public API.
"How do I mock external dependencies?"
Rust mocking isn't as automated as in Java/Python. The idioms: design your code with traits at boundaries (so you can substitute a fake impl), or use crates like mockall. Out of scope for beginner; recognize when you encounter it.
"What about coverage?"
cargo install cargo-tarpaulin then cargo tarpaulin. Reports line/branch coverage. Useful but don't chase 100% - chase confidence.
Done¶
- Write tests in
#[cfg(test)] mod testsmodules. - Use
assert!,assert_eq!,assert_ne!. - Use
#[should_panic]for panic tests. - Write doc tests in
///comments. - Distinguish unit tests from integration tests.