Skill

testing-patterns

Install
1
Install the plugin
$
npx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-rust

Want just this skill?

Add to a custom plugin, then install with one command.

Description

This skill should be used when writing tests for Rust code, running cargo test, setting up test infrastructure, or reviewing test quality in Rust projects.

Tool Access

This skill uses the workspace's default tool permissions.

Skill Content

Rust Testing Patterns and Conventions

This skill defines comprehensive testing patterns for Rust projects, covering unit tests, integration tests, doc tests, property-based testing, benchmarking, mocking, and async testing.

Test Organization

Unit Tests in #[cfg(test)] Modules

Unit tests live in the same file as the code they test, inside a #[cfg(test)] module at the bottom of the file.

// CORRECT: Unit tests in the same file, cfg(test) gated
pub fn is_even(n: i32) -> bool {
    n % 2 == 0
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn even_number_returns_true() {
        assert!(is_even(4));
    }

    #[test]
    fn odd_number_returns_false() {
        assert!(!is_even(3));
    }
}
// WRONG: Tests outside of cfg(test) - compiled into production binary
pub fn is_even(n: i32) -> bool {
    n % 2 == 0
}

#[test]
fn test_is_even() {
    assert!(is_even(4));
}

Integration Tests in tests/ Directory

Integration tests go in the tests/ directory at the crate root. They can only access the public API.

// CORRECT: tests/user_service_test.rs
// Integration test accesses only the public API
use my_crate::UserService;

#[test]
fn creates_and_retrieves_user() {
    let service = UserService::new_in_memory();
    let user = service.create("Alice", "alice@test.com").unwrap();
    let found = service.find_by_id(&user.id).unwrap();
    assert_eq!(found.name, "Alice");
}
// WRONG: Integration test reaching into internals
use my_crate::internal::database::UserRepository; // should not be pub

Shared Test Utilities

Put shared helpers in tests/common/mod.rs and import them from integration test files.

// CORRECT: tests/common/mod.rs
pub fn test_config() -> Config {
    Config {
        database_url: "sqlite::memory:".into(),
        log_level: "off".into(),
        ..Config::default()
    }
}

// tests/api_test.rs
mod common;

#[test]
fn api_responds_to_health_check() {
    let config = common::test_config();
    // ...
}
// WRONG: Duplicating setup in every test file
// tests/api_test.rs
fn test_config() -> Config { /* same code duplicated */ }

// tests/user_test.rs
fn test_config() -> Config { /* same code duplicated */ }

Test Naming Conventions

Descriptive Test Names

Test names should describe the scenario and expected outcome, not mirror the function name.

// CORRECT: Describes the behavior being tested
#[test]
fn parse_returns_error_on_empty_input() { /* ... */ }

#[test]
fn parse_handles_unicode_characters() { /* ... */ }

#[test]
fn cache_evicts_oldest_entry_when_full() { /* ... */ }
// WRONG: Mirrors function name without describing behavior
#[test]
fn test_parse() { /* ... */ }

#[test]
fn test_cache() { /* ... */ }

Naming Pattern

Follow the pattern: <unit>_<scenario>_<expected_result> or <scenario>_<expected_result>.

// CORRECT: Clear pattern
#[test]
fn validate_email_rejects_missing_at_sign() { /* ... */ }

#[test]
fn empty_cart_has_zero_total() { /* ... */ }

#[test]
fn expired_token_returns_unauthorized() { /* ... */ }

Doc Tests

Every Public Function Gets a Doc Test

Doc tests serve as both documentation and tests. They are compiled and run by cargo test.

// CORRECT: Doc test shows usage and is executable
/// Clamps a value between a minimum and maximum.
///
/// # Examples
///
/// ```rust
/// use my_crate::clamp;
///
/// assert_eq!(clamp(5, 0, 10), 5);
/// assert_eq!(clamp(-1, 0, 10), 0);
/// assert_eq!(clamp(20, 0, 10), 10);
/// ```
pub fn clamp(value: i32, min: i32, max: i32) -> i32 {
    value.max(min).min(max)
}
// WRONG: Doc comment without an executable example
/// Clamps a value between a minimum and maximum.
pub fn clamp(value: i32, min: i32, max: i32) -> i32 {
    value.max(min).min(max)
}

Doc Test Annotations

Use annotations to control doc test behavior:

/// Connects to the database (requires running PostgreSQL).
///
/// ```rust,no_run
/// # // no_run: compiles but does not execute
/// let db = Database::connect("postgres://localhost/test").unwrap();
/// ```
pub fn connect(url: &str) -> Result<Database, DbError> { /* ... */ }
/// This function panics on invalid input.
///
/// ```rust,should_panic
/// my_crate::divide(1, 0);
/// ```
pub fn divide(a: i32, b: i32) -> i32 { /* ... */ }

Property-Based Testing with Proptest

When to Use Proptest

Use proptest for:

  • Functions with many valid inputs (parsers, serializers, math)
  • Round-trip properties (serialize then deserialize equals original)
  • Invariants that should hold for all inputs
  • Finding edge cases humans would miss
// CORRECT: Property-based test for a round-trip invariant
#[cfg(test)]
mod tests {
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn encode_decode_roundtrip(input in any::<Vec<u8>>()) {
            let encoded = base64_encode(&input);
            let decoded = base64_decode(&encoded).unwrap();
            prop_assert_eq!(input, decoded);
        }
    }
}
// WRONG: Only testing a few hand-picked inputs for a round-trip
#[cfg(test)]
mod tests {
    #[test]
    fn encode_decode_roundtrip() {
        assert_eq!(base64_decode(&base64_encode(b"hello")).unwrap(), b"hello");
        assert_eq!(base64_decode(&base64_encode(b"")).unwrap(), b"");
        // Missing: many edge cases that proptest would find
    }
}

Custom Strategies

Define custom strategies for domain types:

// CORRECT: Strategy generates valid domain objects
#[cfg(test)]
mod tests {
    use proptest::prelude::*;

    fn valid_port() -> impl Strategy<Value = u16> {
        1024_u16..=65535
    }

    fn valid_host() -> impl Strategy<Value = String> {
        "[a-z]{3,15}(\\.[a-z]{2,5}){1,3}"
    }

    prop_compose! {
        fn server_config()(
            host in valid_host(),
            port in valid_port(),
            max_conn in 1_usize..10000,
        ) -> ServerConfig {
            ServerConfig { host, port, max_connections: max_conn }
        }
    }

    proptest! {
        #[test]
        fn config_serialization_roundtrips(config in server_config()) {
            let json = serde_json::to_string(&config).unwrap();
            let parsed: ServerConfig = serde_json::from_str(&json).unwrap();
            prop_assert_eq!(config, parsed);
        }
    }
}

Benchmarking with Criterion

When to Write Benchmarks

Write benchmarks for:

  • Performance-critical code paths
  • Before and after optimization work
  • Comparing algorithm implementations
  • Regression detection
// CORRECT: Criterion benchmark with parameterized inputs
// benches/parser_bench.rs
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use my_crate::parse;

fn bench_parse(c: &mut Criterion) {
    let mut group = c.benchmark_group("parse");

    for size in [10, 100, 1000, 10000] {
        let input: String = (0..size).map(|i| format!("key{i}=value{i}\n")).collect();
        group.bench_with_input(
            BenchmarkId::from_parameter(size),
            &input,
            |b, input| {
                b.iter(|| parse(black_box(input)));
            },
        );
    }

    group.finish();
}

criterion_group!(benches, bench_parse);
criterion_main!(benches);
// WRONG: Using std::time for benchmarking (inaccurate, no statistical analysis)
#[test]
fn bench_parse() {
    let start = std::time::Instant::now();
    for _ in 0..1000 {
        parse("key=value\n");
    }
    println!("elapsed: {:?}", start.elapsed());
}

Mocking with Mockall

Mock External Boundaries Only

Mock traits that represent external dependencies (databases, HTTP clients, file systems), not internal logic.

// CORRECT: Mock the external boundary (repository trait)
use mockall::automock;

#[automock]
pub trait OrderRepository {
    fn find_by_id(&self, id: &str) -> Result<Option<Order>, DbError>;
    fn save(&self, order: &Order) -> Result<(), DbError>;
}

#[cfg(test)]
mod tests {
    use super::*;
    use mockall::predicate::*;

    #[test]
    fn cancel_order_sets_status_to_cancelled() {
        let mut mock = MockOrderRepository::new();

        mock.expect_find_by_id()
            .with(eq("order-1"))
            .returning(|_| Ok(Some(Order::new("order-1", Status::Pending))));

        mock.expect_save()
            .withf(|order| order.status == Status::Cancelled)
            .returning(|_| Ok(()));

        let service = OrderService::new(mock);
        service.cancel("order-1").unwrap();
    }
}
// WRONG: Mocking internal implementation details
#[automock]
trait StringFormatter {
    fn format(&self, input: &str) -> String;
}

// This is testing the mock, not the real code

Verify Call Counts

Use .times() to assert that dependencies are called the expected number of times.

// CORRECT: Verify the save is called exactly once
#[test]
fn update_user_saves_once() {
    let mut mock = MockUserRepository::new();
    mock.expect_find_by_id().returning(|_| Ok(Some(test_user())));
    mock.expect_save()
        .times(1)  // Exactly once
        .returning(|_| Ok(()));

    let service = UserService::new(mock);
    service.update("user-1", "new-name").unwrap();
}

Async Testing

Use #[tokio::test] for Async Tests

// CORRECT: Async test with tokio
#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn fetches_user_from_api() {
        let client = TestHttpClient::new();
        let user = fetch_user(&client, "user-1").await.unwrap();
        assert_eq!(user.name, "Alice");
    }
}
// WRONG: Manually creating a runtime
#[test]
fn fetches_user_from_api() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        let user = fetch_user("user-1").await.unwrap();
        assert_eq!(user.name, "Alice");
    });
}

Test Timeouts for Async Code

Use tokio::time::timeout to prevent tests from hanging indefinitely.

// CORRECT: Test has an explicit timeout
#[tokio::test]
async fn worker_processes_within_deadline() {
    let result = tokio::time::timeout(
        std::time::Duration::from_secs(5),
        process_work_item(),
    )
    .await;

    assert!(result.is_ok(), "worker timed out after 5 seconds");
    assert!(result.unwrap().is_ok());
}

Time Manipulation in Tests

Use tokio::time::pause() to control time in tests without real delays.

// CORRECT: Deterministic time-based tests
#[tokio::test]
async fn cache_expires_entries() {
    tokio::time::pause();

    let cache = TtlCache::new();
    cache.insert("key", "value", Duration::from_secs(60));

    assert!(cache.get("key").is_some());

    tokio::time::advance(Duration::from_secs(61)).await;

    assert!(cache.get("key").is_none());
}

Test Fixtures

Builder Pattern for Test Data

Use builders to create test data with sensible defaults that can be customized per test.

// CORRECT: Builder with defaults for test data
#[cfg(test)]
struct OrderBuilder {
    id: String,
    customer: String,
    items: Vec<Item>,
    status: Status,
}

#[cfg(test)]
impl Default for OrderBuilder {
    fn default() -> Self {
        Self {
            id: "order-001".into(),
            customer: "test-customer".into(),
            items: vec![Item::new("widget", 1, 999)],
            status: Status::Pending,
        }
    }
}

#[cfg(test)]
impl OrderBuilder {
    fn status(mut self, status: Status) -> Self {
        self.status = status;
        self
    }

    fn items(mut self, items: Vec<Item>) -> Self {
        self.items = items;
        self
    }

    fn build(self) -> Order {
        Order {
            id: self.id,
            customer: self.customer,
            items: self.items,
            status: self.status,
        }
    }
}
// WRONG: Constructing full objects manually in every test
#[test]
fn test_cancel() {
    let order = Order {
        id: "order-001".into(),
        customer: "test".into(),
        items: vec![Item::new("w", 1, 999)],
        status: Status::Pending,
        created_at: Utc::now(),
        updated_at: Utc::now(),
        shipping_address: None,
        billing_address: None,
        notes: String::new(),
    };
    // Every test repeats all these fields even if they are irrelevant
}

Temporary Files and Directories

Use the tempfile crate for tests that need filesystem access.

// CORRECT: Temporary directory auto-cleaned on drop
#[test]
fn writes_and_reads_data() {
    let dir = tempfile::tempdir().unwrap();
    let file_path = dir.path().join("data.json");

    write_data(&file_path, &test_data()).unwrap();
    let loaded = read_data(&file_path).unwrap();

    assert_eq!(loaded, test_data());
    // dir is automatically cleaned up when it goes out of scope
}
// WRONG: Writing to a fixed path that may conflict with other tests
#[test]
fn writes_and_reads_data() {
    let path = "/tmp/test_data.json"; // May conflict with parallel tests
    write_data(path, &test_data()).unwrap();
    let loaded = read_data(path).unwrap();
    assert_eq!(loaded, test_data());
    std::fs::remove_file(path).unwrap(); // Manual cleanup, easy to forget
}

Running Tests

Common Cargo Test Commands

# Run all tests (unit + integration + doc)
cargo test

# Run tests matching a pattern
cargo test parse

# Run tests in a specific module
cargo test --lib service::tests

# Run only integration tests
cargo test --test integration_test

# Run only doc tests
cargo test --doc

# Run with output capture disabled (see println)
cargo test -- --nocapture

# Run ignored tests
cargo test -- --ignored

# Run tests in a single thread (for tests with shared state)
cargo test -- --test-threads=1
Stats
Stars0
Forks0
Last CommitFeb 10, 2026
Actions

Similar Skills