Unit Testing
Writing Unit Tests
Writing effective unit tests structures each test around a single, well-defined behavior using the Arrange-Act-Assert (AAA) pattern, descriptive test function names, and explicit coverage of normal paths, boundary conditions, and error states.
+-------------------------------------------------------------------+
| Arrange - Act - Assert (AAA) |
| |
| 1. Arrange: Construct input values, fixtures, or initial state |
| 2. Act: Invoke the single function or method under test |
| 3. Assert: Verify the returned value and post-conditions |
+-------------------------------------------------------------------+
Structuring Tests with Arrange-Act-Assert
Separating setup, execution, and verification keeps unit tests easy to read and makes failure diagnostics pinpoint the exact broken behavior.
#[derive(Debug, PartialEq, Eq)]
pub enum TransferError {
ZeroAmount,
InsufficientFunds { available: u64, requested: u64 },
}
pub struct Account {
balance: u64,
}
impl Account {
pub fn new(initial: u64) -> Self {
Self { balance: initial }
}
pub fn balance(&self) -> u64 {
self.balance
}
pub fn withdraw(&mut self, amount: u64) -> Result<u64, TransferError> {
if amount == 0 {
return Err(TransferError::ZeroAmount);
}
if amount > self.balance {
return Err(TransferError::InsufficientFunds {
available: self.balance,
requested: amount,
});
}
self.balance -= amount;
Ok(self.balance)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn withdraw_deducts_amount_and_returns_remaining_balance() {
// 1. Arrange
let mut account = Account::new(100);
// 2. Act
let outcome = account.withdraw(40);
// 3. Assert
assert_eq!(outcome, Ok(60));
assert_eq!(account.balance(), 60);
}
#[test]
fn withdraw_exact_balance_leaves_zero() {
let mut account = Account::new(50);
assert_eq!(account.withdraw(50), Ok(0));
assert_eq!(account.balance(), 0);
}
#[test]
fn withdraw_rejects_zero_amount_without_changing_balance() {
let mut account = Account::new(75);
assert_eq!(account.withdraw(0), Err(TransferError::ZeroAmount));
assert_eq!(account.balance(), 75);
}
#[test]
fn withdraw_exceeding_balance_returns_insufficient_funds() {
let mut account = Account::new(30);
assert_eq!(
account.withdraw(50),
Err(TransferError::InsufficientFunds {
available: 30,
requested: 50,
})
);
assert_eq!(account.balance(), 30);
}
}
fn main() {
let mut acct = Account::new(100);
println!("Balance after withdrawing 40: {:?}", acct.withdraw(40));
}
Balance after withdrawing 40: Ok(60)
Table-Driven Tests for Boundary Conditions
When testing a pure function across many input-output pairs (such as empty inputs, single-element inputs, Unicode boundaries, or integer limits), a table-driven loop inside a test avoids repetitive boilerplate while reporting the exact failing case via a custom assertion message:
pub fn slugify(title: &str) -> String {
title
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|part| !part.is_empty())
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>()
.join("-")
}
fn main() {
let cases = [
("Hello, World!", "hello-world"),
(" Multiple Spaces ", "multiple-spaces"),
("Rust 2024: Edition", "rust-2024-edition"),
("---", ""),
("", ""),
];
for (input, expected) in cases {
let actual = slugify(input);
assert_eq!(
actual, expected,
"slugify({input:?}) produced {actual:?}, expected {expected:?}"
);
}
println!("All {} table-driven test cases passed", cases.len());
}
All 5 table-driven test cases passed