Files
Rodin c32dd9b843 docs: testing patterns from rust-lang/rust
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.
2026-04-30 15:06:15 -07:00

665 lines
16 KiB
Markdown

# Rust Testing Patterns
Patterns for writing tests in Rust, extracted from the standard
library source.
**Source:** [rust-lang/rust](https://github.com/rust-lang/rust) at commit
[`f53b654`](https://github.com/rust-lang/rust/tree/f53b654a8882fd5fc036c4ca7a4ff41ce32497a6)
**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:
[library/core/src/option.rs](https://github.com/rust-lang/rust/blob/f53b654a8882fd5fc036c4ca7a4ff41ce32497a6/library/core/src/option.rs)
338 `#[cfg(test)]` modules in the stdlib.
```rust
// 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:**
```rust
// 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:**
```rust
// 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.
```rust
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:**
```rust
#[test]
fn test_parse() {
let result = parse("42");
assert!(result == Ok(42)); // "assertion failed" — which part failed?
}
```
**Example — after:**
```rust
#[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.
```rust
#[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:**
```rust
#[test]
fn test_divide_by_zero_panics() {
// This test always FAILS because the panic propagates
divide(1, 0); // panics — test reports failure
}
```
**Example — after:**
```rust
#[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/`).
```text
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:**
```rust
// 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:**
```rust
// 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:
[library/core/src/panicking.rs](https://github.com/rust-lang/rust/blob/f53b654a8882fd5fc036c4ca7a4ff41ce32497a6/library/core/src/panicking.rs)
389 `#[track_caller]` annotations in the stdlib.
```rust
#[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:**
```rust
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:**
```rust
#[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.
```rust
// 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:**
```rust
#[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:**
```rust
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.
```rust
/// ```
/// 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.
```rust
#[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:**
```rust
#[test]
fn test_parse_errors() {
assert!(parse("").is_err()); // passes, but which error?
assert!(parse("abc").is_err()); // could be any error
}
```
**Example — after:**
```rust
#[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:
```rust
#[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:
```rust
// 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:**
```rust
#[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:**
```rust
#[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](documentation.md) — Doc test attributes
- [error-handling.md](error-handling.md) — Error types worth testing
- [api-design.md](api-design.md) — Testing public API contracts
<!-- PATTERN_COMPLETE -->