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