From ccfg-rust
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ccfg-rust:testing-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill defines comprehensive testing patterns for Rust projects, covering unit tests,
This skill defines comprehensive testing patterns for Rust projects, covering unit tests, integration tests, doc tests, property-based testing, benchmarking, mocking, and async testing.
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 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
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 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() { /* ... */ }
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 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)
}
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 { /* ... */ }
Use proptest for:
// 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
}
}
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);
}
}
}
Write benchmarks for:
// 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());
}
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
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();
}
// 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");
});
}
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());
}
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());
}
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
}
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
}
# 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
npx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-rustProvides Rust testing patterns including unit tests, integration tests, async testing, property-based testing, mocking, and coverage following TDD methodology.
Provides Rust testing patterns for unit, integration, async, property-based tests, mocking, and coverage using TDD workflow with tools like proptest, mockall, rstest.
Reviews Rust test code for unit tests, integration tests, async testing, mocking, and property-based testing. Covers Rust 2024 edition changes. Use when reviewing _test.rs files or #[cfg(test)] modules.