Step 11 of 17
Testing & tooling
Write unit, integration and doc tests with cargo test, test errors and panics, and keep code clean with clippy, rustfmt and CI.
Testing is built into Rust: no framework to choose, no runner to configure. cargo test finds and runs unit tests next to your code, integration tests in their own folder, and code examples in your documentation. Add clippy and rustfmt and you have a full quality toolchain from day one. This lesson covers the three kinds of tests, assertions and test organization, testing errors and panics, test doubles with traits, and a CI setup that catches problems before review.
Unit tests
Unit tests live in the same file as the code, in a module marked #[cfg(test)], which is compiled only when testing:
pub fn slugify(title: &str) -> String {
title
.split_whitespace()
.map(|w| w.to_lowercase())
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*; // bring the parent module's items into scope
#[test]
fn lowercases_and_joins_words() {
assert_eq!(slugify("Hello Rust World"), "hello-rust-world");
}
#[test]
fn collapses_repeated_whitespace() {
assert_eq!(slugify(" a b "), "a-b");
}
#[test]
fn empty_input_gives_empty_slug() {
assert!(slugify("").is_empty());
}
}$ cargo test
running 3 tests
test tests::collapses_repeated_whitespace ... ok
test tests::empty_input_gives_empty_slug ... ok
test tests::lowercases_and_joins_words ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered outBecause the test module is a child of the module under test, it can call private functions. That's deliberate: unit tests check the pieces, including the internal ones.
A test passes if it returns without panicking. The assertion macros:
| Macro | Checks |
|---|---|
assert!(cond) | cond is true |
assert_eq!(left, right) | Equality; prints both values on failure (needs Debug and PartialEq) |
assert_ne!(left, right) | Inequality |
matches!(value, pattern) | Returns bool; use inside assert! to check enum variants |
All of them take an optional format message: assert_eq!(got, 3, "after inserting {item}").
Running tests selectively:
cargo test slugify # only tests whose name contains "slugify"
cargo test -- --nocapture # show println! output from passing tests
cargo test -- --test-threads=1 # run serially (tests run in parallel by default)
cargo test --doc # only documentation testsTesting errors and panics
Tests can return Result, which lets you use ? instead of unwrapping each step:
use std::num::ParseIntError;
fn parse_pair(s: &str) -> Result<(i32, i32), ParseIntError> {
let (a, b) = s.split_once(',').unwrap_or((s, "0"));
Ok((a.trim().parse()?, b.trim().parse()?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_two_numbers() -> Result<(), ParseIntError> {
assert_eq!(parse_pair("3, 4")?, (3, 4));
Ok(())
}
#[test]
fn rejects_non_numbers() {
let err = parse_pair("3, x").unwrap_err();
assert_eq!(err.to_string(), "invalid digit found in string");
}
}For your own error enums, assert on the variant rather than the message, so rewording a message doesn't break tests:
// assert!(matches!(result, Err(ConfigError::OutOfRange(70000))));When a function is supposed to panic (a violated precondition), mark the test #[should_panic], ideally with the expected message:
pub struct Percent(u8);
impl Percent {
pub fn new(v: u8) -> Self {
assert!(v <= 100, "percent must be <= 100, got {v}");
Percent(v)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "must be <= 100")]
fn rejects_over_100() {
Percent::new(101);
}
}Mark slow tests #[ignore] and run them explicitly with cargo test -- --ignored.
Integration tests
Integration tests live in a top-level tests/ folder. Each file there is compiled as a separate crate that uses your library like any external user would, so only pub items are visible:
my_lib/
├── src/lib.rs
└── tests/
├── api.rs
└── common/
└── mod.rs # shared helpers; a subfolder isn't treated as a test file// tests/api.rs
mod common;
use my_lib::slugify;
#[test]
fn slugs_are_url_safe() {
let slug = slugify(&common::sample_title());
assert!(slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
}Integration tests catch a different class of bug: an API that's awkward to use, an item you forgot to make public, modules that work alone but not together. They need a library crate (src/lib.rs); a binary-only package can't be imported, which is one more reason to keep main.rs thin.
Doc tests
Code examples in /// doc comments are compiled and run as tests:
/// Converts a title into a URL slug.
///
/// ```
/// use my_lib::slugify;
/// assert_eq!(slugify("Hello World"), "hello-world");
/// ```
pub fn slugify(title: &str) -> String {
title.split_whitespace().map(str::to_lowercase).collect::<Vec<_>>().join("-")
}Your documentation can't drift out of date, because a broken example fails cargo test. cargo doc --open renders the docs, with the examples, as HTML. Inside a doc example, a line starting with # still runs but is hidden from the rendered docs, which is handy for setup code the reader doesn't need to see.
Test doubles with traits
To test code that talks to a database, clock or HTTP API, depend on a trait and pass a fake in tests. This is plain dependency inversion; Rust needs no mocking framework for it:
pub trait Clock {
fn now_secs(&self) -> u64;
}
pub struct SystemClock;
impl Clock for SystemClock {
fn now_secs(&self) -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock is after 1970")
.as_secs()
}
}
pub struct Session {
pub expires_at: u64,
}
pub fn is_expired(session: &Session, clock: &impl Clock) -> bool {
clock.now_secs() >= session.expires_at
}
#[cfg(test)]
mod tests {
use super::*;
struct FixedClock(u64);
impl Clock for FixedClock {
fn now_secs(&self) -> u64 {
self.0
}
}
#[test]
fn session_expires_at_exact_second() {
let s = Session { expires_at: 1_000 };
assert!(!is_expired(&s, &FixedClock(999)));
assert!(is_expired(&s, &FixedClock(1_000)));
}
}Generic parameters mean production code pays nothing for this seam: is_expired is monomorphized for SystemClock. When you need call recording or expectations, the mockall crate generates mocks from traits, but hand-written fakes like FixedClock are usually clearer. The test doubles concept explains stubs, fakes and mocks in general.
Clippy, rustfmt and CI
rustfmt formats code to the community standard. Run cargo fmt and stop debating style; cargo fmt --check fails if anything's unformatted.
Clippy adds over 700 lints on top of the compiler's warnings: needless clones, inefficient patterns, likely bugs, unidiomatic code.
$ cargo clippy
warning: this `if` has identical blocks
warning: you should consider adding a `Default` implementation for `Store`
warning: called `.iter().count()` on a `Vec`; use `.len()` insteadConfigure lints per crate in Cargo.toml:
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
unwrap_used = "warn"A CI job that runs the full set takes a few lines:
name: ci
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --check
- run: cargo clippy --all-targets --all-features -- -D warnings
- run: cargo test --all-features-D warnings turns warnings into errors in CI only, so local development isn't blocked but nothing unclean gets merged. rust-cache caches target/ and the registry between runs, which matters because cold Rust builds are slow.
Practice
Write a Stack<T> and test it from all three angles.
- In
src/lib.rs, implementpub struct Stack<T>withnew,push,pop -> Option<T>,peek -> Option<&T>,lenandis_empty, backed by aVec<T>. - Add unit tests covering: a new stack is empty; push then pop returns items in reverse order;
popon an empty stack returnsNone;peekdoesn't remove. - Add a doc test on
pushshowing typical use. - Add an integration test in
tests/stack.rsthat pushes 10,000 items and pops them all. - Run
cargo clippyand fix whatever it reports (hint: it may ask for aDefaultimpl).
Show solution
// src/lib.rs
#[derive(Debug, Default)]
pub struct Stack<T> {
items: Vec<T>,
}
impl<T> Stack<T> {
pub fn new() -> Self {
Self { items: Vec::new() }
}
/// Pushes an item onto the top of the stack.
///
/// ```
/// let mut s = stack::Stack::new();
/// s.push(1);
/// s.push(2);
/// assert_eq!(s.peek(), Some(&2));
/// ```
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()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_stack_is_empty() {
let s: Stack<i32> = Stack::new();
assert!(s.is_empty());
assert_eq!(s.len(), 0);
}
#[test]
fn pops_in_reverse_order() {
let mut s = Stack::new();
s.push("a");
s.push("b");
assert_eq!(s.pop(), Some("b"));
assert_eq!(s.pop(), Some("a"));
}
#[test]
fn pop_on_empty_returns_none() {
let mut s: Stack<u8> = Stack::new();
assert_eq!(s.pop(), None);
}
#[test]
fn peek_does_not_remove() {
let mut s = Stack::new();
s.push(7);
assert_eq!(s.peek(), Some(&7));
assert_eq!(s.len(), 1);
}
}// tests/stack.rs
use stack::Stack;
#[test]
fn handles_many_items() {
let mut s = Stack::new();
for i in 0..10_000 {
s.push(i);
}
let popped: Vec<i32> = std::iter::from_fn(|| s.pop()).collect();
assert_eq!(popped.len(), 10_000);
assert_eq!(popped.first(), Some(&9_999));
assert!(s.is_empty());
}The package is named stack, so the doc test and integration test import it as stack::Stack. Clippy's new_without_default lint asks for a Default implementation whenever there's a no-argument new; deriving it satisfies the lint. It also expects is_empty whenever a public len exists, which is already there.
Key takeaways
- Unit tests sit next to the code in a
#[cfg(test)] mod testsand can test private functions. - Integration tests in
tests/are separate crates that see only your public API; doc tests keep examples correct. - Tests can return
Resultand use?; assert on error variants withmatches!, and use#[should_panic(expected = …)]for precondition panics. - Fake dependencies by depending on traits and passing a test implementation; generics keep the seam free in production.
- Run
cargo fmt --check,cargo clippy -- -D warningsandcargo testin CI on every push.
Check yourself
Quiz
0/4 correct · 4 left
1.Why can a unit test in mod tests call a private function from the same file?
2.Which kind of test checks that your public API is usable from outside the crate?
3.What happens to a code example inside a /// doc comment when you run cargo test?
4.Why pass -D warnings to cargo clippy in CI?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.