Lesson 48 of 94
Testing
Rust has a test framework built into the compiler and Cargo. There's nothing to add to Cargo.toml before writing your first test: mark a function with #[test] and run cargo test.
| Task | Rust |
|---|---|
| mark a test | #[test] |
| compare values | assert_eq!(actual, expected) |
| check a condition | assert!(x) |
| expect a failure | #[should_panic], or check the Err a function returns |
| skip a test | #[ignore = "reason"] |
| setup and teardown | a helper function / a guard with Drop |
| parameterized cases | a loop over a table of cases |
| mocking | a hand-written fake that implements a trait (or the mockall crate) |
| where tests live | a #[cfg(test)] mod tests next to the code, plus tests/ for integration tests |
| benchmarks | the criterion crate |
The examples below marked cargo test are compiled as a test binary and run; the output block shows exactly what cargo test prints for them. (Tests run on one thread here so the order is stable. Normally they run in parallel.)
1. Your first test
A test is a function with #[test] that takes no arguments. It passes if it returns normally and fails if it panics. Tests usually live in a child module called tests, marked #[cfg(test)] so it's only compiled when testing.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*; // the parent module's items, including private ones
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn adds_negative_numbers() {
assert_eq!(add(-2, -3), -5);
}
}running 2 tests
test tests::adds_negative_numbers ... ok
test tests::adds_two_numbers ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sTest names are printed with their module path (tests::adds_two_numbers). Name tests as sentences in snake_case; there's no need for a test prefix.
2. The assertion macros
assert! checks a boolean; assert_eq! and assert_ne! compare two values with == and print both on failure (so the type needs PartialEq and Debug). All three take an optional format message.
pub fn normalize_email(raw: &str) -> String {
raw.trim().to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trims_and_lowercases() {
let email = normalize_email(" Ana@Example.COM ");
assert_eq!(email, "ana@example.com");
assert_ne!(email, "Ana@Example.COM");
assert!(email.contains('@'), "expected an @ in {email:?}");
assert!(!email.starts_with(' '));
}
}running 1 test
test tests::trims_and_lowercases ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sArgument order doesn't matter to Rust (the failure shows left and right), but the convention is assert_eq!(actual, expected).
3. Reading a failure
When an assertion fails, the test panics and cargo test prints the left and right values, your message, and the file and line. The other tests keep running.
pub fn word_count(text: &str) -> usize {
text.split(' ').count() // bug: splits on single spaces only
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_simple_sentence() {
assert_eq!(word_count("hello big world"), 3);
}
#[test]
fn ignores_extra_spaces() {
assert_eq!(word_count("hello world "), 2, "input was {:?}", "hello world ");
}
}running 2 tests
test tests::counts_simple_sentence ... ok
test tests::ignores_extra_spaces ... FAILED
failures:
---- tests::ignores_extra_spaces stdout ----
thread 'tests::ignores_extra_spaces' panicked at src/main.rs:16:9:
assertion `left == right` failed: input was "hello world "
left: 5
right: 2
failures:
tests::ignores_extra_spaces
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe fix is text.split_whitespace().count(). Note the exit status: cargo test fails the build (like a failing mvn test), so CI goes red.
4. Testing private functions
Because tests is a child module, use super::* gives it access to private functions. In Rust this is the default arrangement.
pub fn price_with_tax(cents: u64) -> u64 {
cents + tax(cents)
}
// Private: not part of the public API, but still worth testing directly.
fn tax(cents: u64) -> u64 {
(cents * 20 + 50) / 100 // 20%, rounded to the nearest cent
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tax_rounds_to_nearest_cent() {
assert_eq!(tax(100), 20);
assert_eq!(tax(3), 1); // 0.6 cents rounds up
assert_eq!(tax(2), 0); // 0.4 cents rounds down
}
#[test]
fn price_includes_tax() {
assert_eq!(price_with_tax(1000), 1200);
}
}running 2 tests
test tests::price_includes_tax ... ok
test tests::tax_rounds_to_nearest_cent ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sWhether to test private functions is a style question. Testing through the public API is more robust to refactoring; testing a tricky private helper directly gives more precise failures.
5. Expecting a panic: #[should_panic]
#[should_panic] passes only if the test panics. Add expected = "..." to require a substring of the panic message, so the test doesn't pass because of some unrelated panic.
pub struct Percentage(u8);
impl Percentage {
pub fn new(value: u8) -> Percentage {
assert!(value <= 100, "percentage must be at most 100, got {value}");
Percentage(value)
}
pub fn of(&self, amount: u32) -> u32 {
amount * self.0 as u32 / 100
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn computes_share() {
assert_eq!(Percentage::new(25).of(200), 50);
}
#[test]
#[should_panic(expected = "at most 100")]
fn rejects_values_over_100() {
Percentage::new(150);
}
}running 2 tests
test tests::computes_share ... ok
test tests::rejects_values_over_100 - should panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s#[should_panic] is for code that panics by design. For code that returns Result, test the Err value instead (example 7).
6. Tests that return Result
A test can return Result<(), E>. Returning Err fails the test, which lets you use ? instead of unwrap() on every line.
use std::collections::HashMap;
pub fn parse_config(text: &str) -> Result<HashMap<String, String>, String> {
let mut map = HashMap::new();
for (n, line) in text.lines().enumerate() {
let (k, v) = line.split_once('=').ok_or(format!("line {}: missing '='", n + 1))?;
map.insert(k.trim().to_string(), v.trim().to_string());
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_port_and_host() -> Result<(), String> {
let cfg = parse_config("host = localhost\nport = 8080")?;
let port: u16 = cfg["port"].parse().map_err(|e| format!("bad port: {e}"))?;
assert_eq!(port, 8080);
assert_eq!(cfg.get("host").map(String::as_str), Some("localhost"));
Ok(())
}
}running 1 test
test tests::reads_port_and_host ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s7. Testing the error path
For functions that return Result, assert on the error itself. unwrap_err() panics if the result is Ok, which fails the test with a useful message. matches! checks an enum variant without needing PartialEq.
#[derive(Debug, PartialEq)]
pub enum SignupError {
TooShort { min: usize },
Taken(String),
}
pub fn check_username(name: &str, existing: &[&str]) -> Result<(), SignupError> {
if name.len() < 3 {
return Err(SignupError::TooShort { min: 3 });
}
if existing.contains(&name) {
return Err(SignupError::Taken(name.to_string()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_new_name() {
assert!(check_username("ana", &["bob"]).is_ok());
}
#[test]
fn rejects_short_name() {
let err = check_username("al", &[]).unwrap_err();
assert_eq!(err, SignupError::TooShort { min: 3 });
}
#[test]
fn rejects_taken_name() {
let result = check_username("bob", &["bob"]);
assert!(matches!(result, Err(SignupError::Taken(ref n)) if n == "bob"), "got {result:?}");
}
}running 3 tests
test tests::accepts_new_name ... ok
test tests::rejects_short_name ... ok
test tests::rejects_taken_name ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s8. Ignoring slow tests
#[ignore] skips a test unless you ask for it with cargo test -- --ignored (only ignored tests) or cargo test -- --include-ignored (all). Give a reason; it's printed in the report.
pub fn fib(n: u64) -> u64 {
if n < 2 { n } else { fib(n - 1) + fib(n - 2) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn small_values() {
assert_eq!((0..8).map(fib).collect::<Vec<_>>(), [0, 1, 1, 2, 3, 5, 8, 13]);
}
#[test]
#[ignore = "takes several seconds"]
fn large_value() {
assert_eq!(fib(40), 102_334_155);
}
}running 2 tests
test tests::large_value ... ignored, takes several seconds
test tests::small_values ... ok
test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s9. Running a subset of tests
cargo test takes a name filter.
cargo test # everything: unit, integration and doc tests
cargo test tax # tests whose path contains "tax"
cargo test tests::rejects_ -- --exact # --exact: the full name must match
cargo test --lib # only unit tests in src/
cargo test --test checkout # only tests/checkout.rs
cargo test --doc # only doc tests
cargo test -- --nocapture # show println! output of passing tests
cargo test -- --test-threads=1 # run one at a time
cargo test -- --include-ignored # also run #[ignore] testsArguments before -- go to Cargo; arguments after it go to the test binary.
10. Organizing tests in modules
Test modules nest like any other module, and the module path shows up in the output. Group tests by the behavior they cover.
pub struct Stack<T> {
items: Vec<T>,
}
impl<T> Stack<T> {
pub fn new() -> Self {
Stack { items: Vec::new() }
}
pub fn push(&mut self, item: T) {
self.items.push(item);
}
pub fn pop(&mut self) -> Option<T> {
self.items.pop()
}
pub fn peek(&self) -> Option<&T> {
self.items.last()
}
pub fn len(&self) -> usize {
self.items.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
mod when_empty {
use super::*;
#[test]
fn pop_returns_none() {
assert_eq!(Stack::<i32>::new().pop(), None);
}
#[test]
fn len_is_zero() {
assert_eq!(Stack::<i32>::new().len(), 0);
}
}
mod after_pushes {
use super::*;
fn stack() -> Stack<&'static str> {
let mut s = Stack::new();
s.push("a");
s.push("b");
s
}
#[test]
fn peek_shows_last() {
assert_eq!(stack().peek(), Some(&"b"));
}
#[test]
fn pop_is_lifo() {
let mut s = stack();
assert_eq!(s.pop(), Some("b"));
assert_eq!(s.pop(), Some("a"));
assert_eq!(s.pop(), None);
}
}
}running 4 tests
test tests::after_pushes::peek_shows_last ... ok
test tests::after_pushes::pop_is_lifo ... ok
test tests::when_empty::len_is_zero ... ok
test tests::when_empty::pop_returns_none ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s11. Fixtures: setup with helper functions
There's no @BeforeEach. Each test calls a helper that builds what it needs, which keeps every test self-contained and makes the setup visible. A builder-style helper lets each test tweak only what matters to it.
#[derive(Debug, Clone)]
pub struct Order {
pub items: Vec<(String, u32)>,
pub coupon: Option<String>,
pub member: bool,
}
pub fn total(order: &Order) -> u32 {
let subtotal: u32 = order.items.iter().map(|(_, cents)| cents).sum();
let mut discount = 0;
if order.member {
discount += subtotal / 10;
}
if order.coupon.as_deref() == Some("SAVE5") {
discount += 500;
}
subtotal.saturating_sub(discount)
}
#[cfg(test)]
mod tests {
use super::*;
// The fixture: a typical order that tests adjust.
fn order() -> Order {
Order {
items: vec![(String::from("book"), 2000), (String::from("pen"), 500)],
coupon: None,
member: false,
}
}
#[test]
fn plain_order() {
assert_eq!(total(&order()), 2500);
}
#[test]
fn member_gets_ten_percent() {
assert_eq!(total(&Order { member: true, ..order() }), 2250);
}
#[test]
fn coupon_and_member_stack() {
let o = Order { member: true, coupon: Some(String::from("SAVE5")), ..order() };
assert_eq!(total(&o), 1750);
}
#[test]
fn discount_never_goes_negative() {
let o = Order { items: vec![(String::from("sticker"), 100)], coupon: Some(String::from("SAVE5")), ..order() };
assert_eq!(total(&o), 0);
}
}running 4 tests
test tests::coupon_and_member_stack ... ok
test tests::discount_never_goes_negative ... ok
test tests::member_gets_ten_percent ... ok
test tests::plain_order ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe struct update syntax ..order() is the key trick: "the standard fixture, except for these fields".
12. Teardown with Drop
For cleanup that must happen even when the test fails (@AfterEach), use a guard whose Drop does the cleanup. A failing assertion panics, and the panic still runs destructors as it unwinds.
use std::fs;
use std::path::PathBuf;
pub fn save_note(dir: &std::path::Path, name: &str, text: &str) -> std::io::Result<PathBuf> {
let path = dir.join(format!("{name}.txt"));
fs::write(&path, text)?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
struct TempDir(PathBuf);
impl TempDir {
fn new(name: &str) -> TempDir {
let path = std::env::temp_dir().join(format!("notes-test-{name}-{}", std::process::id()));
fs::create_dir_all(&path).unwrap();
TempDir(path)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0); // runs even if the test panicked
}
}
#[test]
fn writes_file() {
let dir = TempDir::new("writes");
let path = save_note(&dir.0, "todo", "buy milk").unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), "buy milk");
}
#[test]
fn overwrites_existing() {
let dir = TempDir::new("overwrites");
save_note(&dir.0, "todo", "old").unwrap();
let path = save_note(&dir.0, "todo", "new").unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), "new");
}
}running 2 tests
test tests::overwrites_existing ... ok
test tests::writes_file ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sIn real projects the tempfile crate provides exactly this guard (tempfile::tempdir()). Each test gets its own directory, which matters because tests run in parallel.
13. Table-driven tests
Instead of @ParameterizedTest with a @CsvSource, write a table of cases and loop over it. Include the case in the failure message so you know which row failed.
pub fn parse_duration(s: &str) -> Option<u64> {
let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
let n: u64 = num.parse().ok()?;
let factor = match unit {
"s" => 1,
"m" => 60,
"h" => 3600,
"d" => 86_400,
_ => return None,
};
n.checked_mul(factor)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_durations() {
let cases = [
("30s", Some(30)),
("5m", Some(300)),
("2h", Some(7200)),
("1d", Some(86_400)),
("10", None), // no unit
("m", None), // no number
("5w", None), // unknown unit
("-5s", None), // sign not allowed
];
for (input, expected) in cases {
assert_eq!(parse_duration(input), expected, "input: {input:?}");
}
}
}running 1 test
test tests::parses_durations ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sA table is one test, so the first failing row stops it. When each case deserves its own name in the report, a small macro_rules! can generate one #[test] per case; the Macros lesson shows how.
14. Comparing floats
Floating-point results are rarely exactly equal. Compare within a tolerance with a small helper.
pub fn mean(xs: &[f64]) -> f64 {
xs.iter().sum::<f64>() / xs.len() as f64
}
pub fn std_dev(xs: &[f64]) -> f64 {
let m = mean(xs);
(xs.iter().map(|x| (x - m).powi(2)).sum::<f64>() / xs.len() as f64).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_close(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-9, "expected {expected}, got {actual}");
}
#[test]
fn tenths_do_not_add_up_exactly() {
assert_ne!(0.1 + 0.2, 0.3);
assert_close(0.1 + 0.2, 0.3);
}
#[test]
fn statistics() {
let data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
assert_close(mean(&data), 5.0);
assert_close(std_dev(&data), 2.0);
}
}running 2 tests
test tests::statistics ... ok
test tests::tenths_do_not_add_up_exactly ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s15. Test-only code with #[cfg(test)]
Anything marked #[cfg(test)] exists only in test builds: helper constructors, extra trait impls, or a PartialEq you don't want in the public API.
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq))] // equality only needed by the tests
pub struct Session {
user: String,
token: String,
}
impl Session {
pub fn login(user: &str, password: &str) -> Option<Session> {
(password == "hunter2").then(|| Session { user: user.to_string(), token: format!("tok-{user}") })
}
}
#[cfg(test)]
impl Session {
// A shortcut constructor that production code can't call.
fn for_test(user: &str) -> Session {
Session { user: user.to_string(), token: format!("tok-{user}") }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_with_right_password() {
assert_eq!(Session::login("ana", "hunter2"), Some(Session::for_test("ana")));
}
#[test]
fn login_with_wrong_password() {
assert_eq!(Session::login("ana", "letmein"), None);
}
}running 2 tests
test tests::login_with_right_password ... ok
test tests::login_with_wrong_password ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s16. Fakes through traits
Rust has no runtime proxies, so code depends on a trait, and the test passes a small fake implementation.
use std::collections::HashMap;
pub trait UserRepo {
fn find_email(&self, user_id: u32) -> Option<String>;
}
pub struct Notifier<R: UserRepo> {
repo: R,
}
impl<R: UserRepo> Notifier<R> {
pub fn new(repo: R) -> Self {
Notifier { repo }
}
pub fn reminder(&self, user_id: u32) -> Result<String, String> {
let email = self.repo.find_email(user_id).ok_or(format!("user {user_id} not found"))?;
Ok(format!("To: {email}\nSubject: Your cart is waiting"))
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeRepo(HashMap<u32, String>);
impl UserRepo for FakeRepo {
fn find_email(&self, user_id: u32) -> Option<String> {
self.0.get(&user_id).cloned()
}
}
fn notifier() -> Notifier<FakeRepo> {
Notifier::new(FakeRepo(HashMap::from([(1, String::from("ana@example.com"))])))
}
#[test]
fn builds_reminder_for_known_user() {
let msg = notifier().reminder(1).unwrap();
assert!(msg.starts_with("To: ana@example.com"));
}
#[test]
fn fails_for_unknown_user() {
assert_eq!(notifier().reminder(9).unwrap_err(), "user 9 not found");
}
}running 2 tests
test tests::builds_reminder_for_known_user ... ok
test tests::fails_for_unknown_user ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s17. Spies: recording calls
To check how a dependency was used, let the fake record its calls. Trait methods usually take &self, so the recording goes through a RefCell: a container whose borrow_mut() lets you change its contents through a shared & reference. Cell & RefCell explains it in detail later; here it's just a list the fake can push to.
use std::cell::RefCell;
pub trait Mailer {
fn send(&self, to: &str, body: &str);
}
pub fn notify_overdue(mailer: &impl Mailer, invoices: &[(&str, u32)]) -> usize {
let mut sent = 0;
for (customer, days) in invoices {
if *days > 30 {
mailer.send(customer, &format!("Invoice overdue by {days} days"));
sent += 1;
}
}
sent
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct SpyMailer {
sent: RefCell<Vec<(String, String)>>,
}
impl Mailer for SpyMailer {
fn send(&self, to: &str, body: &str) {
self.sent.borrow_mut().push((to.to_string(), body.to_string()));
}
}
#[test]
fn emails_only_overdue_customers() {
let spy = SpyMailer::default();
let count = notify_overdue(&spy, &[("ana", 45), ("ben", 10), ("cy", 31)]);
assert_eq!(count, 2);
let sent = spy.sent.borrow();
let recipients: Vec<&str> = sent.iter().map(|(to, _)| to.as_str()).collect();
assert_eq!(recipients, ["ana", "cy"]);
assert_eq!(sent[0].1, "Invoice overdue by 45 days");
}
}running 1 test
test tests::emails_only_overdue_customers ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sFor large interfaces, the mockall crate generates mocks from a trait with #[automock], including expect_*().times(1).returning(...).
18. Injecting time
Code that calls "now" directly is hard to test. Pass in a clock (a trait, or just a value) so tests can pick the time.
pub trait Clock {
fn now_secs(&self) -> u64;
}
pub struct Token {
pub expires_at: u64,
}
pub fn is_valid(token: &Token, clock: &impl Clock) -> bool {
clock.now_secs() < token.expires_at
}
pub fn refresh_needed(token: &Token, clock: &impl Clock) -> bool {
token.expires_at.saturating_sub(clock.now_secs()) < 300 // under 5 minutes left
}
#[cfg(test)]
mod tests {
use super::*;
struct FixedClock(u64);
impl Clock for FixedClock {
fn now_secs(&self) -> u64 {
self.0
}
}
const TOKEN: Token = Token { expires_at: 10_000 };
#[test]
fn valid_before_expiry() {
assert!(is_valid(&TOKEN, &FixedClock(9_999)));
assert!(!is_valid(&TOKEN, &FixedClock(10_000)));
}
#[test]
fn refresh_in_last_five_minutes() {
assert!(!refresh_needed(&TOKEN, &FixedClock(9_000)));
assert!(refresh_needed(&TOKEN, &FixedClock(9_800)));
assert!(refresh_needed(&TOKEN, &FixedClock(20_000)));
}
}running 2 tests
test tests::refresh_in_last_five_minutes ... ok
test tests::valid_before_expiry ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe production Clock would use std::time::SystemTime::now().
19. Deterministic randomness
The same idea for randomness: accept a source of random numbers so tests can use a fixed sequence. Here a closure is enough; no trait needed.
/// Picks a server, skipping unhealthy ones. `rand_index(n)` returns a number in 0..n.
pub fn pick_server<'a>(servers: &[(&'a str, bool)], mut rand_index: impl FnMut(usize) -> usize) -> Option<&'a str> {
let healthy: Vec<&str> = servers.iter().filter(|(_, ok)| *ok).map(|(name, _)| *name).collect();
if healthy.is_empty() {
return None;
}
Some(healthy[rand_index(healthy.len())])
}
#[cfg(test)]
mod tests {
use super::*;
const SERVERS: [(&str, bool); 3] = [("a", true), ("b", false), ("c", true)];
#[test]
fn never_picks_unhealthy() {
// Try every possible "random" choice.
for i in 0..2 {
let picked = pick_server(&SERVERS, |_| i).unwrap();
assert_ne!(picked, "b");
}
}
#[test]
fn uses_the_random_index() {
let mut calls = vec![];
let picked = pick_server(&SERVERS, |n| {
calls.push(n);
1
});
assert_eq!(picked, Some("c"));
assert_eq!(calls, [2]); // asked for a number below 2 (two healthy servers)
}
#[test]
fn none_when_all_down() {
assert_eq!(pick_server(&[("a", false)], |_| 0), None);
}
}running 3 tests
test tests::never_picks_unhealthy ... ok
test tests::none_when_all_down ... ok
test tests::uses_the_random_index ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s20. Testing output by writing to impl Write
Code that prints straight to stdout is hard to check. Write to any std::io::Write instead: pass std::io::stdout() in production and a Vec<u8> in tests.
use std::io::{self, Write};
pub fn print_table(out: &mut impl Write, rows: &[(&str, u32)]) -> io::Result<()> {
let width = rows.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
for (name, qty) in rows {
writeln!(out, "{name:<width$} | {qty:>3}")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aligns_columns() {
let mut buf = Vec::new();
print_table(&mut buf, &[("apples", 3), ("kiwi", 12)]).unwrap();
let text = String::from_utf8(buf).unwrap();
assert_eq!(text, "apples | 3\nkiwi | 12\n");
}
#[test]
fn empty_table_prints_nothing() {
let mut buf = Vec::new();
print_table(&mut buf, &[]).unwrap();
assert!(buf.is_empty());
}
}running 2 tests
test tests::aligns_columns ... ok
test tests::empty_table_prints_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s21. Tests run in parallel
By default cargo test runs tests on several threads at once. Tests that share global state (a file, an environment variable, a static) can interfere with each other. Give each test its own resources, or protect shared ones.
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
pub fn next_id() -> u32 {
NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
#[cfg(test)]
mod tests {
use super::*;
// Fragile: `assert_eq!(next_id(), 1)` would depend on which test runs first.
// Robust: only assert things that hold whatever other tests do.
#[test]
fn ids_increase() {
let a = next_id();
let b = next_id();
assert!(b > a);
}
#[test]
fn ids_are_unique_across_threads() {
let handles: Vec<_> = (0..4).map(|_| std::thread::spawn(|| (0..100).map(|_| next_id()).collect::<Vec<_>>())).collect();
let mut all: Vec<u32> = handles.into_iter().flat_map(|h| h.join().unwrap()).collect();
all.sort();
all.dedup();
assert_eq!(all.len(), 400);
}
}running 2 tests
test tests::ids_are_unique_across_threads ... ok
test tests::ids_increase ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sIf tests truly must not overlap (they share a database, say), a static LOCK: Mutex<()> that each test locks is the simplest fix; cargo test -- --test-threads=1 is the blunt one. The Threads lesson covers spawn and join.
22. Integration tests: the tests/ directory
Unit tests sit next to the code. Integration tests live in tests/ at the package root. Each file there is compiled as a separate crate that depends on your library, so it can only use the public API, exactly like a real user.
invoicer/
├── src/
│ └── lib.rs
└── tests/
├── checkout.rs one test crate
├── refunds.rs another test crate
└── common/
└── mod.rs shared helpers (not a test crate itself)// tests/common/mod.rs
use invoicer::Cart;
pub fn cart_with(items: &[(&str, u32)]) -> Cart {
let mut cart = Cart::new();
for (name, cents) in items {
cart.add(name, *cents);
}
cart
}// tests/checkout.rs
mod common; // pulls in tests/common/mod.rs
use invoicer::checkout;
#[test]
fn checkout_totals_the_cart() {
let cart = common::cart_with(&[("book", 2000), ("pen", 500)]);
let receipt = checkout(&cart).unwrap();
assert_eq!(receipt.total_cents, 2500);
}Only library crates can have integration tests, which is one more reason to keep main.rs thin and put the logic in lib.rs.
| Kind | Rust |
|---|---|
| unit test | #[cfg(test)] mod tests in the same file |
| integration test | a file in tests/ |
| shared test utilities | tests/common/mod.rs, or a dev-dependency crate in the workspace |
23. Doc tests
The previous lesson showed that code blocks in /// comments run as tests. They double as usage examples, so they're the natural place to test the "happy path" of a public API.
/// A length in meters.
///
/// ```
/// use units::Meters;
///
/// let run = Meters::from_km(5.0) + Meters(250.0);
/// assert_eq!(run.0, 5250.0);
/// assert_eq!(run.to_string(), "5.25 km");
/// ```
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Meters(pub f64);
impl Meters {
/// ```
/// assert_eq!(units::Meters::from_km(1.5), units::Meters(1500.0));
/// ```
pub fn from_km(km: f64) -> Meters {
Meters(km * 1000.0)
}
}
impl std::ops::Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
impl std::fmt::Display for Meters {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
if self.0 >= 1000.0 {
write!(f, "{} km", self.0 / 1000.0)
} else {
write!(f, "{} m", self.0)
}
}
}running 2 tests
test src/lib.rs - Meters (line 3) ... ok
test src/lib.rs - Meters::from_km (line 14) ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s24. Property-style testing
Example-based tests check the cases you thought of. Property-based tests check a rule against many generated inputs: "sorting twice is the same as sorting once", "decode(encode(x)) == x". Here is the idea by hand, with a tiny deterministic generator:
pub fn run_length_encode(s: &str) -> Vec<(char, usize)> {
let mut out: Vec<(char, usize)> = Vec::new();
for c in s.chars() {
match out.last_mut() {
Some((last, n)) if *last == c => *n += 1,
_ => out.push((c, 1)),
}
}
out
}
pub fn run_length_decode(runs: &[(char, usize)]) -> String {
runs.iter().map(|&(c, n)| c.to_string().repeat(n)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
// A small linear congruential generator: same seed, same "random" strings.
fn random_strings(count: usize) -> Vec<String> {
let mut state: u64 = 42;
let mut next = move || {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(state >> 33) as usize
};
(0..count)
.map(|_| {
let len = next() % 20;
(0..len).map(|_| ['a', 'b', 'c'][next() % 3]).collect()
})
.collect()
}
#[test]
fn decode_inverts_encode() {
for s in random_strings(500) {
assert_eq!(run_length_decode(&run_length_encode(&s)), s, "input {s:?}");
}
}
#[test]
fn neighbouring_runs_differ() {
for s in random_strings(500) {
let runs = run_length_encode(&s);
assert!(runs.windows(2).all(|w| w[0].0 != w[1].0), "input {s:?} gave {runs:?}");
}
}
}running 2 tests
test tests::decode_inverts_encode ... ok
test tests::neighbouring_runs_differ ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe proptest crate does this properly: it generates inputs from strategies and, when a test fails, shrinks the input to the smallest failing case.
use proptest::prelude::*;
proptest! {
#[test]
fn decode_inverts_encode(s in "[abc]{0,20}") {
prop_assert_eq!(run_length_decode(&run_length_encode(&s)), s);
}
}25. Benchmarks
Benchmarks need optimized builds and statistics. The standard choice is the criterion crate, placed in benches/.
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "parsing"
harness = false # criterion provides its own main// benches/parsing.rs
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;
fn bench_parse(c: &mut Criterion) {
c.bench_function("parse 1k lines", |b| {
let input = "key=value\n".repeat(1000);
b.iter(|| invoicer::parse_config(black_box(&input)))
});
}
criterion_group!(benches, bench_parse);
criterion_main!(benches);cargo bench # runs benches/ with the release profileblack_box stops the optimizer from deleting work whose result is unused. For a rough measurement without a crate, time a loop in a --release build:
use std::hint::black_box;
use std::time::Instant;
fn sum_of_squares(n: u64) -> u64 {
(1..=n).map(|x| x * x).sum()
}
fn main() {
let start = Instant::now();
let mut total = 0;
for _ in 0..1_000 {
total += sum_of_squares(black_box(10_000));
}
println!("result {total} in {:?}", start.elapsed());
}26. Realistic: testing a shopping cart
A small module with a thorough test suite: happy paths, edge cases, errors, and an invariant check.
use std::collections::BTreeMap;
#[derive(Debug, PartialEq)]
pub enum CartError {
UnknownProduct(String),
ZeroQuantity,
OutOfStock { product: String, available: u32 },
}
pub struct Cart<'a> {
catalog: &'a BTreeMap<&'a str, (u32, u32)>, // name -> (price in cents, stock)
lines: BTreeMap<String, u32>,
}
impl<'a> Cart<'a> {
pub fn new(catalog: &'a BTreeMap<&'a str, (u32, u32)>) -> Self {
Cart { catalog, lines: BTreeMap::new() }
}
pub fn add(&mut self, product: &str, qty: u32) -> Result<(), CartError> {
if qty == 0 {
return Err(CartError::ZeroQuantity);
}
let &(_, stock) = self.catalog.get(product).ok_or_else(|| CartError::UnknownProduct(product.to_string()))?;
let current = self.lines.get(product).copied().unwrap_or(0);
if current + qty > stock {
return Err(CartError::OutOfStock { product: product.to_string(), available: stock - current });
}
self.lines.insert(product.to_string(), current + qty);
Ok(())
}
pub fn remove(&mut self, product: &str) -> bool {
self.lines.remove(product).is_some()
}
pub fn total_cents(&self) -> u32 {
self.lines.iter().map(|(name, qty)| self.catalog[name.as_str()].0 * qty).sum()
}
pub fn item_count(&self) -> u32 {
self.lines.values().sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn catalog() -> BTreeMap<&'static str, (u32, u32)> {
BTreeMap::from([("book", (1500, 3)), ("pen", (200, 100))])
}
#[test]
fn empty_cart_totals_zero() {
let cat = catalog();
let cart = Cart::new(&cat);
assert_eq!((cart.total_cents(), cart.item_count()), (0, 0));
}
#[test]
fn adding_accumulates_quantity() {
let cat = catalog();
let mut cart = Cart::new(&cat);
cart.add("pen", 2).unwrap();
cart.add("pen", 3).unwrap();
cart.add("book", 1).unwrap();
assert_eq!(cart.item_count(), 6);
assert_eq!(cart.total_cents(), 5 * 200 + 1500);
}
#[test]
fn rejects_unknown_and_zero() {
let cat = catalog();
let mut cart = Cart::new(&cat);
assert_eq!(cart.add("laptop", 1), Err(CartError::UnknownProduct(String::from("laptop"))));
assert_eq!(cart.add("pen", 0), Err(CartError::ZeroQuantity));
}
#[test]
fn stock_limit_counts_what_is_already_in_cart() {
let cat = catalog();
let mut cart = Cart::new(&cat);
cart.add("book", 2).unwrap();
let err = cart.add("book", 2).unwrap_err();
assert_eq!(err, CartError::OutOfStock { product: String::from("book"), available: 1 });
assert_eq!(cart.item_count(), 2, "a failed add must not change the cart");
}
#[test]
fn remove_reports_whether_anything_was_removed() {
let cat = catalog();
let mut cart = Cart::new(&cat);
cart.add("pen", 1).unwrap();
assert!(cart.remove("pen"));
assert!(!cart.remove("pen"));
assert_eq!(cart.total_cents(), 0);
}
}running 5 tests
test tests::adding_accumulates_quantity ... ok
test tests::empty_cart_totals_zero ... ok
test tests::rejects_unknown_and_zero ... ok
test tests::remove_reports_whether_anything_was_removed ... ok
test tests::stock_limit_counts_what_is_already_in_cart ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s27. Realistic: testing a parser's error messages
Good error messages are part of a parser's behavior, so they deserve tests. Table-driven tests keep the many failure cases readable.
#[derive(Debug, PartialEq)]
pub struct Rgb(pub u8, pub u8, pub u8);
pub fn parse_color(s: &str) -> Result<Rgb, String> {
let hex = s.strip_prefix('#').ok_or_else(|| format!("{s:?}: must start with '#'"))?;
let hex = match hex.len() {
6 => hex.to_string(),
3 => hex.chars().flat_map(|c| [c, c]).collect(), // #abc -> #aabbcc
n => return Err(format!("{s:?}: expected 3 or 6 hex digits, found {n}")),
};
let channel = |i: usize| {
u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| format!("{s:?}: invalid hex {:?}", &hex[i..i + 2]))
};
Ok(Rgb(channel(0)?, channel(2)?, channel(4)?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_colors() {
for (input, expected) in [
("#ff8000", Rgb(255, 128, 0)),
("#000000", Rgb(0, 0, 0)),
("#FFF", Rgb(255, 255, 255)),
("#1a2", Rgb(0x11, 0xaa, 0x22)),
] {
assert_eq!(parse_color(input), Ok(expected), "input {input:?}");
}
}
#[test]
fn error_messages() {
for (input, message) in [
("ff8000", r#""ff8000": must start with '#'"#),
("#ff80", r##""#ff80": expected 3 or 6 hex digits, found 4"##),
("#gg0000", r##""#gg0000": invalid hex "gg""##),
("#12345z", r##""#12345z": invalid hex "5z""##),
] {
assert_eq!(parse_color(input).unwrap_err(), message, "input {input:?}");
}
}
}running 2 tests
test tests::error_messages ... ok
test tests::valid_colors ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s28. Realistic: a service tested with fakes end to end
A signup service with two dependencies, a user store and a mailer, tested with an in-memory store and a spy mailer. This is the shape most business-logic tests take in Rust.
use std::cell::RefCell;
use std::collections::HashSet;
pub trait UserStore {
fn exists(&self, email: &str) -> bool;
fn insert(&mut self, email: &str) -> u32;
}
pub trait Mailer {
fn send_welcome(&self, email: &str);
}
pub struct SignupService<S: UserStore, M: Mailer> {
pub store: S,
pub mailer: M,
}
impl<S: UserStore, M: Mailer> SignupService<S, M> {
pub fn signup(&mut self, raw_email: &str) -> Result<u32, String> {
let email = raw_email.trim().to_lowercase();
if !email.contains('@') {
return Err(format!("invalid email {raw_email:?}"));
}
if self.store.exists(&email) {
return Err(format!("{email} is already registered"));
}
let id = self.store.insert(&email);
self.mailer.send_welcome(&email);
Ok(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct MemoryStore {
emails: HashSet<String>,
}
impl UserStore for MemoryStore {
fn exists(&self, email: &str) -> bool {
self.emails.contains(email)
}
fn insert(&mut self, email: &str) -> u32 {
self.emails.insert(email.to_string());
self.emails.len() as u32
}
}
#[derive(Default)]
struct SpyMailer {
welcomed: RefCell<Vec<String>>,
}
impl Mailer for SpyMailer {
fn send_welcome(&self, email: &str) {
self.welcomed.borrow_mut().push(email.to_string());
}
}
fn service() -> SignupService<MemoryStore, SpyMailer> {
SignupService { store: MemoryStore::default(), mailer: SpyMailer::default() }
}
#[test]
fn new_user_is_stored_and_welcomed() {
let mut svc = service();
assert_eq!(svc.signup(" Ana@Example.com "), Ok(1));
assert!(svc.store.exists("ana@example.com"));
assert_eq!(*svc.mailer.welcomed.borrow(), ["ana@example.com"]);
}
#[test]
fn duplicate_is_rejected_without_email() {
let mut svc = service();
svc.signup("ana@example.com").unwrap();
let err = svc.signup("ANA@example.com").unwrap_err();
assert_eq!(err, "ana@example.com is already registered");
assert_eq!(svc.mailer.welcomed.borrow().len(), 1, "no second welcome mail");
}
#[test]
fn invalid_email_touches_nothing() {
let mut svc = service();
assert!(svc.signup("not-an-email").is_err());
assert!(svc.store.emails.is_empty());
assert!(svc.mailer.welcomed.borrow().is_empty());
}
}running 3 tests
test tests::duplicate_is_rejected_without_email ... ok
test tests::invalid_email_touches_nothing ... ok
test tests::new_user_is_stored_and_welcomed ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s29. Testing a whole program's behavior
Tests can also check a binary end to end by running it with std::process::Command. Cargo sets CARGO_BIN_EXE_<name> for integration tests so they can find the built binary.
// tests/cli.rs
use std::process::Command;
#[test]
fn prints_usage_without_arguments() {
let output = Command::new(env!("CARGO_BIN_EXE_invoicer")).output().unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("usage: invoicer <file>"));
}Crates like assert_cmd and insta (snapshot testing) make these tests shorter.
30. What cargo test runs, all together
$ cargo test
Compiling invoicer v0.3.1
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.52s
Running unittests src/lib.rs (target/debug/deps/invoicer-3f2a...)
running 12 tests
...
Running tests/checkout.rs (target/debug/deps/checkout-9c1e...)
running 3 tests
...
Doc-tests invoicer
running 4 tests
...One command runs three kinds of tests: unit tests from src/, each integration test crate in tests/, and doc tests. There's no plugin to configure and no separate test runner, which makes cargo test the standard step in every Rust CI pipeline, usually alongside cargo clippy -- -D warnings and cargo fmt --check.
Summary
- A
#[test]function passes if it returns and fails if it panics. Put unit tests in#[cfg(test)] mod testswithuse super::*;, which can see private items. - Use
assert!,assert_eq!(actual, expected),assert_ne!, andmatches!, with a message that identifies the case. - Use
#[should_panic(expected = "...")]for intended panics; returnResultfrom tests to use?; assert onunwrap_err()for error paths. - Fixtures are plain helper functions (
..fixture()for variations); teardown is aDropguard; parameterized tests are loops over tables. - Mock with traits: fakes for behavior, spies with
RefCellto record calls, injected clocks and random sources. - Integration tests in
tests/use only the public API; doc tests keep examples honest;proptestandcriterioncover property tests and benchmarks. - Tests run in parallel, so avoid shared mutable state.
This completes the Modules, Crates & Testing section. Next: Custom Error Types, the start of Error Handling.
