Test Patterns
Knowing the #[test] attribute gets you started. Writing maintainable tests that catch real bugs requires patterns. This covers the techniques that keep test suites readable, fast, and reliable as a codebase grows.
Arrange-Act-Assert
The most fundamental test pattern. Every test has three phases:
#[test]
fn test_user_creation() {
// Arrange: set up the preconditions
let email = "alice@example.com";
let name = "Alice";
// Act: perform the operation under test
let user = User::new(email, name);
// Assert: verify the outcome
assert_eq!(user.email(), email);
assert_eq!(user.name(), name);
assert!(user.is_active());
}
Keep each phase clearly separated, even if it means a slightly longer test. When a test fails, you want to immediately see which phase broke.
Test Fixtures & Setup
When multiple tests share setup logic, extract it into helper functions:
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> Config {
Config {
host: "localhost".into(),
port: 8080,
max_connections: 100,
timeout_ms: 5000,
}
}
fn sample_user() -> User {
User::new("test@example.com", "Test User")
}
#[test]
fn test_config_validation() {
let config = sample_config();
assert!(config.validate().is_ok());
}
#[test]
fn test_config_with_invalid_port() {
let mut config = sample_config();
config.port = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_user_display() {
let user = sample_user();
assert_eq!(format!("{}", user), "Test User <test@example.com>");
}
}
Fixture functions should return owned values so each test gets an independent copy. Avoid mutable shared state between tests.
Testing Error Paths with Result
Use Result-returning tests to exercise error handling without .unwrap() chains:
#[derive(Debug)]
pub enum ParseError {
InvalidFormat(String),
OutOfRange(i64),
}
pub fn parse_port(input: &str) -> Result<u16, ParseError> {
let n: i64 = input
.parse()
.map_err(|_| ParseError::InvalidFormat(input.into()))?;
if n < 1 || n > 65535 {
return Err(ParseError::OutOfRange(n));
}
Ok(n as u16)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_port() -> Result<(), ParseError> {
let port = parse_port("8080")?;
assert_eq!(port, 8080);
Ok(())
}
#[test]
fn test_invalid_format() {
let err = parse_port("abc").unwrap_err();
assert!(matches!(err, ParseError::InvalidFormat(_)));
}
#[test]
fn test_port_out_of_range() {
let err = parse_port("99999").unwrap_err();
assert!(matches!(err, ParseError::OutOfRange(99999)));
}
#[test]
fn test_negative_port() {
let err = parse_port("-1").unwrap_err();
assert!(matches!(err, ParseError::OutOfRange(-1)));
}
}
matches! is particularly useful for checking enum variants without destructuring. .unwrap_err() asserts that the result is an error and returns it for further inspection.
Table-Driven Tests
When you test the same function with many inputs, table-driven tests eliminate boilerplate:
pub fn slugify(input: &str) -> String {
input
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<&str>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slugify() {
let cases = vec![
("Hello World", "hello-world"),
("Rust is Great!", "rust-is-great"),
(" multiple spaces ", "multiple-spaces"),
("already-slugified", "already-slugified"),
("ALLCAPS", "allcaps"),
("special@#$chars", "special-chars"),
("trailing-", "trailing"),
];
for (input, expected) in cases {
assert_eq!(
slugify(input),
expected,
"slugify({:?}) should be {:?}",
input,
expected
);
}
}
}
The custom message in assert_eq! is critical. Without it, a failure reports two strings that do not match but does not tell you which input caused the failure.
Parameterized Testing with Macros
For more structured parameterized tests where each case should be a separate test (so failures are independent), use a macro:
macro_rules! test_cases {
($($name:ident: $input:expr => $expected:expr),* $(,)?) => {
$(
#[test]
fn $name() {
assert_eq!(slugify($input), $expected);
}
)*
};
}
#[cfg(test)]
mod tests {
use super::*;
test_cases! {
simple_words: "Hello World" => "hello-world",
with_punctuation: "Rust is Great!" => "rust-is-great",
multiple_spaces: " multiple spaces " => "multiple-spaces",
already_slug: "already-slugified" => "already-slugified",
all_caps: "ALLCAPS" => "allcaps",
}
}
$ cargo test
running 5 tests
test tests::all_caps ... ok
test tests::already_slug ... ok
test tests::multiple_spaces ... ok
test tests::simple_words ... ok
test tests::with_punctuation ... ok
Each case becomes a named test. When one fails, you see exactly which case broke without re-running the entire suite.
Test Helpers
Extract complex assertions into helper functions. The #[track_caller] attribute makes error messages point to the calling test, not the helper:
#[cfg(test)]
mod tests {
use super::*;
#[track_caller]
fn assert_parses_to(input: &str, expected: &Config) {
let result = parse_config(input);
assert!(result.is_ok(), "Failed to parse: {:?}", input);
let config = result.unwrap();
assert_eq!(&config, expected, "Config mismatch for input: {:?}", input);
}
#[test]
fn test_minimal_config() {
assert_parses_to(
"port = 8080",
&Config { port: 8080, host: "localhost".into(), ..Config::default() },
);
}
#[test]
fn test_full_config() {
assert_parses_to(
"port = 3000\nhost = 0.0.0.0",
&Config { port: 3000, host: "0.0.0.0".into(), ..Config::default() },
);
}
}
Without #[track_caller], failures point to the helper function's line, which is unhelpful. With it, failures point to the test that called the helper.
Snapshot Testing with insta
The insta crate captures output and compares it against a saved snapshot. Use assert_debug_snapshot! or assert_snapshot! to record complex output (JSON, HTML, debug representations) on the first run, then verify it has not changed on subsequent runs. When output changes intentionally, run cargo insta review to accept the new snapshot interactively.
Snapshot tests excel at catching unintended changes in serialization formats, display output, and structured data. They are not a substitute for asserting specific properties, but they are a safety net for complex output where writing expected values by hand is tedious.
Temporary Files & Directories
Tests that need filesystem access should use tempfile:
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_file_processing() -> Result<(), Box<dyn std::error::Error>> {
let mut file = NamedTempFile::new()?;
writeln!(file, "line 1")?;
writeln!(file, "line 2")?;
writeln!(file, "line 3")?;
let count = count_lines(file.path())?;
assert_eq!(count, 3);
Ok(())
// file is automatically deleted when dropped
}
Temp files are cleaned up automatically. No leftover test artifacts cluttering your filesystem.
Common Pitfalls
- Giant test functions — a test that is 100 lines long tests too many things. Split it into focused tests with clear names.
- Missing failure messages —
assert_eq!(a, b)with no message gives you two values that do not match and no context. Always add messages for non-obvious assertions. - Snapshot test churn — snapshot tests break on every cosmetic change (whitespace, ordering). Use them for stable output formats, not volatile ones.
- Testing the mock instead of the code — when your test has more mock setup than assertions, you are testing mock configuration, not behavior.
- Shared mutable state — tests run in parallel. A test that writes to a global or static variable will interfere with other tests. Use
--test-threads=1as a last resort; prefer independent state. - No edge case tests — empty strings, zero values, maximum values, Unicode input. The happy path works; the edges is where bugs live.
Key Takeaways
- Use arrange-act-assert as the structure for every test. Keep the three phases visually distinct.
- Extract shared setup into fixture functions that return owned values.
- Table-driven tests eliminate repetition. Add custom failure messages so you know which case broke.
- Parameterized test macros give each case its own test name, making failures easier to locate.
#[track_caller]on helper functions makes error messages point to the right line.- Snapshot testing with insta catches unintended changes in complex output without manual expected values.