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

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:

library/core/src/option.rs

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:

library/core/src/panicking.rs

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: