c32dd9b843
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.
665 lines
16 KiB
Markdown
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 -->
|