10 patterns, 664 lines. Full spec compliance. Patterns: #[cfg(test)] modules, assert_eq!, #[should_panic], integration tests, #[track_caller], test helpers, doc tests, error testing, conditional compilation, property-based testing.
16 KiB
Rust Testing Patterns
Patterns for writing tests in Rust, extracted from the standard library source.
Source: rust-lang/rust at commit
f53b654
Stats: 4,136 #[test] functions, 338 #[cfg(test)] modules,
16,728 assert macros, 275+ #[should_panic] tests,
389 #[track_caller] annotations.
1. #[cfg(test)] Module Organization
Source:
338 #[cfg(test)] modules in the stdlib.
// Tests live in a cfg(test) module at the bottom of the file:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_option_is_some() {
let x: Option<i32> = Some(2);
assert!(x.is_some());
}
}
Why
#[cfg(test)] means the module is only compiled when running tests.
It's not included in the release binary. The module has full access
to private items via use super::*.
When to Use
Triggers:
- Unit tests for the same module (testing private functions)
- Small, focused tests that don't need external resources
- Tests that access private implementation details
Example — before:
// Tests in a separate file can't access private functions
// tests/my_module.rs
use my_crate::public_function;
// can't test `private_helper` directly
Example — after:
// src/my_module.rs
fn private_helper(x: i32) -> i32 { x * 2 }
pub fn public_function(x: i32) -> i32 { private_helper(x) + 1 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn helper_doubles() {
assert_eq!(private_helper(5), 10); // can test private fn
}
#[test]
fn public_uses_helper() {
assert_eq!(public_function(5), 11);
}
}
When NOT to Use
Don't use this when:
- Integration tests (testing the public API only — use
tests/dir) - Tests need separate binaries or processes
- You're testing across multiple modules together
2. assert_eq! / assert_ne! with Descriptive Messages
Source:
16,728 assert macros across the stdlib test code. assert_eq! is
overwhelmingly preferred over raw assert! for comparisons.
assert_eq!(result, expected);
assert_eq!(result, expected, "failed for input {input}");
assert_ne!(left, right, "values should differ after mutation");
Why
assert_eq! prints both values on failure. assert!(a == b) only
says "assertion failed" — useless for debugging. Always use the
specific macro that matches your assertion type.
When to Use
Triggers:
- Comparing two values: use
assert_eq! - Values should differ: use
assert_ne! - Boolean condition: use
assert! - All: add context message for non-obvious failures
Example — before:
#[test]
fn test_parse() {
let result = parse("42");
assert!(result == Ok(42)); // "assertion failed" — which part failed?
}
Example — after:
#[test]
fn test_parse() {
assert_eq!(parse("42"), Ok(42));
// On failure: "left: Err(ParseError), right: Ok(42)"
assert_eq!(
parse("not_a_number"),
Err(ParseError::InvalidDigit),
"parsing non-numeric string should give InvalidDigit"
);
}
When NOT to Use
Don't use this when:
- Comparing floats (use approximate comparison:
(a - b).abs() < epsilon) - The Debug output is huge and unhelpful (write a custom assertion)
3. #[should_panic] for Panic Testing
Source:
275+ #[should_panic] tests across the stdlib.
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_index_out_of_bounds() {
let v = vec![1, 2, 3];
let _ = v[99];
}
Why
Tests that SHOULD panic need explicit marking. Without
#[should_panic], the test framework considers a panic a failure.
The expected parameter verifies the panic message contains a
specific substring — prevents the test from passing due to an
unrelated panic.
When to Use
Triggers:
- Testing that invalid input causes a panic
- Verifying invariant violations panic correctly
- Testing
unwrap()/expect()behavior
Example — before:
#[test]
fn test_divide_by_zero_panics() {
// This test always FAILS because the panic propagates
divide(1, 0); // panics — test reports failure
}
Example — after:
#[test]
#[should_panic(expected = "division by zero")]
fn test_divide_by_zero_panics() {
divide(1, 0); // panics with "division by zero" — test PASSES
}
When NOT to Use
Don't use this when:
- You want to verify the panic payload is a specific type (use
std::panic::catch_unwind) - The function should return an error instead of panicking (fix the function!)
- You need to test what happens AFTER the panic (the test ends at the panic point)
4. Integration Tests (tests/ Directory)
Source:
The stdlib uses both #[cfg(test)] modules and separate test
crates (coretests/, alloctests/).
my_crate/
├── src/
│ └── lib.rs
└── tests/
├── integration_test.rs ← each file is a separate crate
└── common/
└── mod.rs ← shared test utilities
Why
Integration tests only access the public API. They compile as
separate crates. This tests what users actually experience.
Each file in tests/ is a separate binary — they run in parallel.
When to Use
Triggers:
- Testing the public API from a user's perspective
- Testing interactions between multiple modules
- Ensuring your library works as a dependency
Example — before:
// All tests in src/lib.rs — mixing unit and integration concerns
#[cfg(test)]
mod tests {
#[test]
fn test_internal_detail() { ... } // unit test
#[test]
fn test_user_workflow() { ... } // integration test — doesn't belong here
}
Example — after:
// tests/user_workflow.rs — clean integration test
use my_crate::{Config, Server};
#[test]
fn server_starts_and_handles_request() {
let config = Config::default();
let server = Server::new(config);
let response = server.handle("GET /");
assert_eq!(response.status(), 200);
}
When NOT to Use
Don't use this when:
- You need to test private internals (use
#[cfg(test)]module) - The test is quick and doesn't need isolation (unit test is fine)
5. #[track_caller] for Better Error Location
Source:
389 #[track_caller] annotations in the stdlib.
#[track_caller]
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
Why
Without #[track_caller], a panic in a helper function shows the
helper's location, not where it was called from. This attribute
makes the panic point to the CALLER — where the bug actually is.
When to Use
Triggers:
- Helper functions that panic (custom assert functions)
- Wrapper functions around
unwrap()/expect() - Any function where the caller is responsible for preconditions
Example — before:
fn assert_valid_port(port: u16) {
assert!(port > 0 && port < 65535, "invalid port: {port}");
// Panic location: this line ← not helpful
}
#[test]
fn test_connection() {
assert_valid_port(0); // user wants to see THIS line in the error
}
Example — after:
#[track_caller]
fn assert_valid_port(port: u16) {
assert!(port > 0 && port < 65535, "invalid port: {port}");
// Panic location: the CALLER's line ← helpful!
}
#[test]
fn test_connection() {
assert_valid_port(0); // error points here — where the bug is
}
When NOT to Use
Don't use this when:
- The function doesn't panic
- The panic is an internal bug (the caller ISN'T at fault)
- Performance-critical hot paths (small overhead from tracking)
6. Test Helpers and Shared Fixtures
Source:
The stdlib uses helper modules for shared test infrastructure.
// tests/common/mod.rs — shared test utilities
pub fn setup_temp_dir() -> PathBuf { ... }
pub fn create_test_file(dir: &Path, name: &str) -> PathBuf { ... }
Why
DRY test setup. Common fixtures, builders, and assertions live in shared modules. This reduces boilerplate without sacrificing clarity.
When to Use
Triggers:
- Multiple tests need the same setup/teardown
- Custom assertion logic used across test files
- Test data builders (construct complex test objects)
Example — before:
#[test]
fn test_parse_valid() {
let input = r#"{"name": "test", "port": 8080}"#;
let config = Config { name: "test".into(), port: 8080, ..Default::default() };
assert_eq!(parse(input).unwrap(), config);
}
#[test]
fn test_parse_with_tls() {
let input = r#"{"name": "test", "port": 443, "tls": true}"#;
let config = Config { name: "test".into(), port: 443, tls: true, ..Default::default() };
assert_eq!(parse(input).unwrap(), config);
}
// Duplicated config construction everywhere
Example — after:
fn config_builder() -> ConfigBuilder {
ConfigBuilder::new().name("test").port(8080)
}
#[test]
fn test_parse_valid() {
let input = r#"{"name": "test", "port": 8080}"#;
assert_eq!(parse(input).unwrap(), config_builder().build());
}
#[test]
fn test_parse_with_tls() {
let input = r#"{"name": "test", "port": 443, "tls": true}"#;
assert_eq!(parse(input).unwrap(), config_builder().port(443).tls(true).build());
}
7. Doc Tests as Executable Examples
Source:
144 doc test blocks in option.rs alone. Every public function has testable examples in its documentation.
/// ```
/// let x: Option<u32> = Some(2);
/// assert_eq!(x.unwrap(), 2);
/// ```
Why
Doc tests serve dual purpose: they document behavior AND verify
correctness. cargo test runs all doc tests. When the implementation
changes, broken doc tests force documentation updates.
When to Use
Triggers:
- Demonstrating how to use a public API
- Showing expected input/output pairs
- Proving that examples in docs are correct
When NOT to Use
Don't use this when:
- The test requires complex setup (use
#[test]function instead) - Testing edge cases not relevant to documentation
- Performance testing (doc tests have startup overhead)
8. Testing Error Conditions
Source:
Extensive error case testing throughout the stdlib.
#[test]
fn parse_empty_string_returns_error() {
let result: Result<i32, _> = "".parse();
assert!(result.is_err());
}
#[test]
fn file_not_found_error_kind() {
let err = fs::read("/nonexistent").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
Why
Test the error paths, not just the happy path. Verify WHICH error occurs, not just that "some error" happens. Pattern match on error variants to ensure correct error classification.
When to Use
Triggers:
- Every function that returns Result
- Boundary conditions (empty input, zero, max value)
- Invalid state transitions
Example — before:
#[test]
fn test_parse_errors() {
assert!(parse("").is_err()); // passes, but which error?
assert!(parse("abc").is_err()); // could be any error
}
Example — after:
#[test]
fn parse_empty_gives_empty_input_error() {
assert_eq!(parse(""), Err(ParseError::EmptyInput));
}
#[test]
fn parse_non_numeric_gives_invalid_digit() {
assert_eq!(parse("abc"), Err(ParseError::InvalidDigit('a')));
}
#[test]
fn parse_overflow_gives_overflow_error() {
assert_eq!(
parse("999999999999999999"),
Err(ParseError::Overflow { max: u32::MAX })
);
}
9. Conditional Compilation for Platform Tests
Source:
#[test]
#[cfg(unix)]
fn test_unix_permissions() {
use std::os::unix::fs::PermissionsExt;
let perms = fs::metadata("/tmp").unwrap().permissions();
assert!(perms.mode() & 0o777 != 0);
}
#[test]
#[cfg(windows)]
fn test_windows_path() {
assert!(Path::new("C:\\").is_absolute());
}
Why
Platform-specific tests only compile on the target platform. This prevents compilation errors on other platforms while still testing platform-specific behavior where it matters.
When to Use
Triggers:
- Testing OS-specific APIs (Unix permissions, Windows paths)
- Testing platform-specific behavior
- Features only available on certain architectures
When NOT to Use
Don't use this when:
- The test can be written in a platform-independent way
- You're using cfg to SKIP broken tests (fix them instead)
10. Property-Based Testing (Invariant Verification)
Source:
50 property-test-like patterns in the stdlib. The standard library uses exhaustive and randomized testing for numeric operations:
// From coretests — testing mathematical properties
#[test]
fn test_add_is_commutative() {
for a in 0..100u32 {
for b in 0..100u32 {
assert_eq!(a.wrapping_add(b), b.wrapping_add(a));
}
}
}
Why
Property-based tests verify invariants hold for ALL inputs, not just hand-picked examples. They catch edge cases that example-based tests miss. Even without a full property testing framework, you can test properties with loops and random inputs.
When to Use
Triggers:
- Mathematical properties (commutativity, associativity, idempotency)
- Round-trip invariants (encode then decode = original)
- Ordering properties (sort then check sorted)
- Any "for all X, property Y holds"
Example — before:
#[test]
fn test_serialize_roundtrip() {
let value = MyStruct { x: 42, y: "hello".into() };
let bytes = serialize(&value);
let result = deserialize(&bytes).unwrap();
assert_eq!(result, value);
// Tests ONE specific input — what about empty strings? Large numbers?
}
Example — after:
#[test]
fn serialize_roundtrip_property() {
// Test with many inputs — catches edge cases
let test_cases = vec![
MyStruct { x: 0, y: "".into() },
MyStruct { x: u32::MAX, y: "hello".into() },
MyStruct { x: 1, y: "a".repeat(10000) },
MyStruct { x: 42, y: "\0\n\t".into() },
];
for value in test_cases {
let bytes = serialize(&value);
let result = deserialize(&bytes)
.unwrap_or_else(|e| panic!("roundtrip failed for {value:?}: {e}"));
assert_eq!(result, value, "roundtrip failed for {value:?}");
}
}
// With proptest crate:
proptest! {
#[test]
fn roundtrip(x in any::<u32>(), y in ".*") {
let value = MyStruct { x, y };
let bytes = serialize(&value);
let result = deserialize(&bytes).unwrap();
prop_assert_eq!(result, value);
}
}
Summary: Testing Decision Tree
What are you testing?
├── Private internals → #[cfg(test)] mod tests in same file
├── Public API from user perspective → tests/ directory
├── Documentation correctness → Doc tests (```)
└── Platform-specific behavior → #[cfg(target_os)] on test
What kind of assertion?
├── Two values equal → assert_eq!(left, right)
├── Two values differ → assert_ne!(left, right)
├── Boolean condition → assert!(condition)
├── Should panic → #[should_panic(expected = "...")]
├── Should error → assert_eq!(result, Err(specific_error))
└── Property holds for all inputs → Loop/proptest
Need shared setup?
├── Simple → helper function in test module
├── Complex → tests/common/mod.rs
└── Custom assertion → #[track_caller] helper function
| Pattern | Use when |
|---|---|
#[cfg(test)] mod tests |
Unit testing (private access) |
tests/ directory |
Integration testing (public API) |
assert_eq! / assert_ne! |
Comparing values (shows both on failure) |
#[should_panic] |
Verifying panics occur |
#[track_caller] |
Custom assert helpers (better error location) |
| Doc tests | Executable documentation examples |
| Error testing | Every Result-returning function |
#[cfg(unix/windows)] |
Platform-specific tests |
| Property-based | Invariant verification (roundtrips, math) |
| Shared helpers | DRY test setup/fixtures |
See also:
- documentation.md — Doc test attributes
- error-handling.md — Error types worth testing
- api-design.md — Testing public API contracts