Writing Tests
Prerequisites: Working with JSON using serde
Testing is built into Rust and Cargo: no extra framework is needed to start. A
test is an ordinary function marked with #[test]. cargo test compiles your
code in test mode, runs every test (in parallel, on several threads) and
reports which ones passed. A test fails if it panics.
There are three kinds of tests:
- Unit tests sit next to the code they test, in a
testsmodule inside the same file. They can call private functions. - Integration tests live in a top-level
tests/directory. Each file there is compiled as a separate crate that uses your library only through its public API, exactly as a user would. - Doc tests are code examples inside documentation comments; the next lesson covers them.
Syntax
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)] // only compiled for `cargo test`, not in normal builds
mod tests {
use super::*; // bring the parent module's items into scope
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}
| Macro | Fails when |
|---|---|
assert!(cond) |
cond is false |
assert_eq!(left, right) |
the values differ (prints both) |
assert_ne!(left, right) |
the values are equal |
All three accept an optional message with format arguments:
assert!(x > 0, "x was {x}").
Example
A library package named bank, src/lib.rs:
#[derive(Debug, PartialEq)]
pub enum BankError {
InsufficientFunds { needed: u64, available: u64 },
}
pub struct Account {
balance: u64,
}
impl Account {
pub fn new(balance: u64) -> Self {
Account { balance }
}
pub fn balance(&self) -> u64 {
self.balance
}
pub fn deposit(&mut self, amount: u64) {
assert!(amount > 0, "deposit must be positive");
self.balance += amount;
}
pub fn withdraw(&mut self, amount: u64) -> Result<(), BankError> {
if amount > self.balance {
return Err(BankError::InsufficientFunds { needed: amount, available: self.balance });
}
self.balance -= amount;
Ok(())
}
pub fn monthly_fee(&self) -> u64 {
fee_for(self.balance)
}
}
fn fee_for(amount: u64) -> u64 {
amount / 100 // private helper: 1% fee
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deposit_increases_balance() {
let mut acct = Account::new(10);
acct.deposit(5);
assert_eq!(acct.balance(), 15);
}
#[test]
fn withdraw_too_much_is_an_error() {
let mut acct = Account::new(10);
let result = acct.withdraw(50);
assert_eq!(result, Err(BankError::InsufficientFunds { needed: 50, available: 10 }));
assert_eq!(acct.balance(), 10, "balance must not change on failure");
}
#[test]
fn private_functions_can_be_tested() {
assert_eq!(fee_for(250), 2);
}
#[test]
#[should_panic(expected = "deposit must be positive")]
fn zero_deposit_panics() {
Account::new(0).deposit(0);
}
// A test can return Result and use `?`; an Err fails the test.
#[test]
fn withdraw_then_check() -> Result<(), BankError> {
let mut acct = Account::new(100);
acct.withdraw(40)?;
assert_eq!(acct.balance(), 60);
Ok(())
}
}
An integration test, tests/transfers.rs (it can only use the public API):
use bank::Account;
#[test]
fn several_operations() {
let mut acct = Account::new(0);
acct.deposit(100);
acct.withdraw(30).unwrap();
assert_eq!(acct.balance(), 70);
}
Run everything with cargo test.
Expected output
running 5 tests
test tests::deposit_increases_balance ... ok
test tests::private_functions_can_be_tested ... ok
test tests::withdraw_then_check ... ok
test tests::withdraw_too_much_is_an_error ... ok
test tests::zero_deposit_panics - should panic ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
running 1 test
test several_operations ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo also prints Running ... headers and a doc-test section (with 0 tests
here). Because tests run in parallel, the order of the test ... ok lines and
the timings can vary between runs.
Useful cargo test options
cargo test withdraw # run only tests whose name contains "withdraw"
cargo test --test transfers # run only the integration test file tests/transfers.rs
cargo test -- --nocapture # show println! output from passing tests
cargo test -- --test-threads=1 # run tests one at a time
cargo test -- --ignored # run tests marked #[ignore]
Arguments before -- go to Cargo; arguments after it go to the test binary.
Common mistakes
- Forgetting
use super::*;insidemod tests:error[E0433]: failed to resolve: use of undeclared type `Account`. - Leaving out
#[cfg(test)]. The tests still run, but test-only code and imports are compiled into your normal build, withunusedwarnings. - Using
assert!(a == b)instead ofassert_eq!(a, b). Both work, but onlyassert_eq!shows the two values when it fails. It needs the values to implementPartialEqandDebug. - A
#[should_panic]test withoutexpected = "..."passes for any panic, including an unrelated bug. Give the expected message text. - Writing integration tests for a binary-only package (no
src/lib.rs). Files intests/can only use a library crate; move the logic intolib.rs. - Tests that depend on each other or share files. They run in parallel and in no fixed order, so each test should set up its own data.
Practice
Write pub fn is_leap_year(year: u32) -> bool (divisible by 4, except
centuries, unless divisible by 400) with a test module containing:
a test that 2024 and 2000 are leap years, a test that 1900 and 2023 are not,
and a test returning Result<(), String> that fails with a message if 1600 is
not a leap year.
Show solution
pub fn is_leap_year(year: u32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leap_years() {
assert!(is_leap_year(2024));
assert!(is_leap_year(2000));
}
#[test]
fn common_years() {
assert!(!is_leap_year(1900), "centuries are not leap years");
assert!(!is_leap_year(2023));
}
#[test]
fn year_1600() -> Result<(), String> {
if is_leap_year(1600) {
Ok(())
} else {
Err(String::from("1600 should be a leap year"))
}
}
}
cargo test reports test result: ok. 3 passed; 0 failed; ....