| name | rust-testing-verification |
| description | Comprehensive testing strategies for Rust including property-based testing with proptest, fuzz testing with cargo-fuzz, benchmark testing with Criterion, contract testing for traits, and Miri for undefined behavior detection. Use when writing tests, ensuring code correctness, detecting edge cases, or measuring performance. |
Rust Testing & Verification
Production testing strategies for correctness and performance validation
Version Context
- proptest: 1.x
- Criterion: 0.5.x
- cargo-fuzz: 0.11.x
- cargo-miri: Latest nightly
When to Use This Skill
- Writing comprehensive test suites
- Finding edge cases automatically
- Verifying trait implementations
- Benchmarking performance
- Detecting undefined behavior
- Ensuring code correctness
Property-Based Testing
Basic Property Tests
use proptest::prelude::*;
proptest! {
/// Property: Serialization round-trip preserves data
#[test]
fn user_serialization_roundtrip(user in any::<User>()) {
let serialized = serde_json::to_string(&user)?;
let deserialized: User = serde_json::from_str(&serialized)?;
prop_assert_eq!(user, deserialized);
}
/// Property: Email validation accepts valid emails
#[test]
fn email_validation_accepts_valid_emails(
email in r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
) {
let result = Email::parse(&email);
prop_assert!(result.is_ok());
}
/// Property: Vec operations maintain size invariants
#[test]
fn vec_push_increases_len(
mut vec in prop::collection::vec(any::<i32>(), 0..100),
value in any::<i32>()
) {
let original_len = vec.len();
vec.push(value);
prop_assert_eq!(vec.len(), original_len + 1);
}
}
Advanced Property Tests
proptest! {
/// Property: Account balance invariants
#[test]
fn account_balance_invariants(
initial_balance in 0u64..1_000_000,
transactions in prop::collection::vec(
prop::oneof![
(1u64..10_000).prop_map(Transaction::Deposit),
(1u64..10_000).prop_map(Transaction::Withdrawal),
],
1..100
)
) {
let mut account = Account::new(initial_balance);
let mut expected_balance = initial_balance;
for transaction in transactions {
match transaction {
Transaction::Deposit(amount) => {
account.deposit(amount)?;
expected_balance += amount;
}
Transaction::Withdrawal(amount) => {
if account.balance() >= amount {
account.withdraw(amount)?;
expected_balance -= amount;
}
}
}
// Invariant: balance must always be non-negative
prop_assert!(account.balance() >= 0);
prop_assert_eq!(account.balance(), expected_balance);
}
}
/// Property: Sorted vector stays sorted after insertion
#[test]
fn sorted_insert_maintains_order(
mut sorted_vec in prop::collection::vec(any::<i32>(), 0..100)
.prop_map(|mut v| { v.sort(); v }),
value in any::<i32>()
) {
sorted_vec.insert(
sorted_vec.binary_search(&value).unwrap_or_else(|i| i),
value
);
// Verify still sorted
for i in 1..sorted_vec.len() {
prop_assert!(sorted_vec[i - 1] <= sorted_vec[i]);
}
}
}
Custom Generators
use proptest::strategy::{Strategy, BoxedStrategy};
/// Custom strategy for generating valid users
fn arb_user() -> BoxedStrategy<User> {
(
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
r"[A-Z][a-z]+ [A-Z][a-z]+",
13u8..=120u8,
)
.prop_map(|(email, name, age)| {
User {
id: UserId::new(),
email,
name,
age,
created_at: chrono::Utc::now(),
}
})
.boxed()
}
proptest! {
#[test]
fn test_with_valid_users(user in arb_user()) {
// Test only runs with valid user data
prop_assert!(user.age >= 13 && user.age <= 120);
prop_assert!(user.email.contains('@'));
}
}
Fuzz Testing
Basic Fuzz Target
// fuzz/fuzz_targets/parse_input.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
// Should never panic on arbitrary input
if let Ok(s) = std::str::from_utf8(data) {
let _ = parse_user_input(s);
}
});
Structured Fuzzing
// fuzz/fuzz_targets/api_request.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
use arbitrary::Arbitrary;
#[derive(Debug, Arbitrary)]
struct FuzzApiRequest {
method: String,
path: String,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
fuzz_target!(|req: FuzzApiRequest| {
// Test API handler doesn't panic on arbitrary inputs
let _ = handle_request(
req.method,
req.path,
req.headers,
req.body,
);
});
Regression Tests from Fuzzing
#[cfg(test)]
mod fuzz_regression_tests {
use super::*;
/// Edge cases discovered by fuzzing
#[test]
fn test_known_edge_cases() {
let edge_cases = vec![
"", // Empty input
"\0", // Null byte
"🦀", // Unicode
&"x".repeat(10_000), // Large input
"\n\r\t", // Whitespace
"{{{{", // Unbalanced braces
];
for case in edge_cases {
// Should handle gracefully without panicking
let result = parse_user_input(case);
assert!(result.is_ok() || result.is_err());
}
}
}
Benchmark Testing
Basic Criterion Benchmarks
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
fn benchmark_user_operations(c: &mut Criterion) {
let mut group = c.benchmark_group("user_operations");
// Benchmark with different input sizes
for size in [10, 100, 1000, 10000].iter() {
group.bench_with_input(
BenchmarkId::new("lookup", size),
size,
|b, &size| {
let users = generate_test_users(size);
b.iter(|| {
let id = &users[rand::random::<usize>() % users.len()].id;
black_box(lookup_user(black_box(id)))
})
},
);
}
group.finish();
}
criterion_group!(benches, benchmark_user_operations);
criterion_main!(benches);
Advanced Benchmarking
use criterion::{Criterion, BenchmarkId, Throughput};
use std::time::Duration;
fn benchmark_serialization(c: &mut Criterion) {
let mut group = c.benchmark_group("serialization");
// Configure statistical parameters
group.sample_size(100);
group.measurement_time(Duration::from_secs(10));
group.confidence_level(0.95);
for size in [10, 100, 1000].iter() {
let data = generate_data(*size);
// Set throughput for bytes per second calculation
group.throughput(Throughput::Bytes(data.len() as u64));
group.bench_with_input(
BenchmarkId::new("json", size),
&data,
|b, data| {
b.iter(|| {
let serialized = serde_json::to_string(black_box(data)).unwrap();
black_box(serialized)
})
},
);
group.bench_with_input(
BenchmarkId::new("bincode", size),
&data,
|b, data| {
b.iter(|| {
let serialized = bincode::serialize(black_box(data)).unwrap();
black_box(serialized)
})
},
);
}
group.finish();
}
Allocation Benchmarking
#[cfg(test)]
mod allocation_tests {
use super::*;
#[test]
fn test_zero_allocation_path() {
let allocations_before = allocation_counter::current();
// Critical path that should not allocate
let result = process_request_zero_alloc(&input);
let allocations_after = allocation_counter::current();
let total_allocations = allocations_after - allocations_before;
assert_eq!(
total_allocations, 0,
"Critical path allocated {} bytes",
total_allocations
);
}
}
Contract Testing
Trait Contract Tests
use async_trait::async_trait;
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn get_user(&self, id: UserId) -> Result<User, RepositoryError>;
async fn save_user(&self, user: &User) -> Result<(), RepositoryError>;
}
/// Contract tests that all implementations must satisfy
#[cfg(test)]
pub mod contract_tests {
use super::*;
pub async fn test_user_repository_contract<R: UserRepository>(repo: R) {
// Test: Save and retrieve should be consistent
let user = User::new("test@example.com".to_string(), "Test User".to_string());
repo.save_user(&user).await.unwrap();
let retrieved = repo.get_user(user.id).await.unwrap();
assert_eq!(user.id, retrieved.id);
assert_eq!(user.email, retrieved.email);
assert_eq!(user.name, retrieved.name);
}
pub async fn test_user_repository_not_found<R: UserRepository>(repo: R) {
// Test: Getting non-existent user should return error
let non_existent_id = UserId::new();
let result = repo.get_user(non_existent_id).await;
assert!(matches!(result, Err(RepositoryError::NotFound)));
}
}
/// Apply contract tests to concrete implementation
#[tokio::test]
async fn postgres_repository_satisfies_contract() {
let repo = PostgresUserRepository::new(get_test_db().await);
contract_tests::test_user_repository_contract(repo.clone()).await;
contract_tests::test_user_repository_not_found(repo).await;
}
#[tokio::test]
async fn in_memory_repository_satisfies_contract() {
let repo = InMemoryUserRepository::new();
contract_tests::test_user_repository_contract(repo.clone()).await;
contract_tests::test_user_repository_not_found(repo).await;
}
Miri for Undefined Behavior Detection
Using Miri
# Install Miri
rustup +nightly component add miri
# Run tests with Miri
cargo +nightly miri test
# Run specific test
cargo +nightly miri test test_concurrent_access
Miri-Compatible Tests
#[cfg(test)]
mod miri_tests {
use super::*;
#[test]
fn test_safe_concurrent_access() {
use std::sync::Arc;
use std::thread;
let counter = Arc::new(AtomicCounter::new());
let mut handles = vec![];
for _ in 0..10 {
let counter_clone = counter.clone();
handles.push(thread::spawn(move || {
for _ in 0..100 {
counter_clone.increment();
}
}));
}
for handle in handles {
handle.join().unwrap();
}
assert_eq!(counter.get(), 1000);
}
}
Table-Driven Tests
Data-Driven Test Cases
#[cfg(test)]
mod table_driven_tests {
use super::*;
#[test]
fn test_email_validation() {
let test_cases = vec![
("test@example.com", true),
("user+tag@domain.co.uk", true),
("invalid.email", false),
("@example.com", false),
("user@", false),
("", false),
];
for (input, expected_valid) in test_cases {
let result = Email::parse(input);
assert_eq!(
result.is_ok(),
expected_valid,
"Email validation failed for: {}",
input
);
}
}
#[test]
fn test_status_code_mapping() {
let test_cases = vec![
(ApiError::ValidationError(_), StatusCode::BAD_REQUEST),
(ApiError::Unauthorized, StatusCode::UNAUTHORIZED),
(ApiError::Forbidden, StatusCode::FORBIDDEN),
(ApiError::NotFound, StatusCode::NOT_FOUND),
(ApiError::InternalError, StatusCode::INTERNAL_SERVER_ERROR),
];
for (error, expected_status) in test_cases {
let status = error.status_code();
assert_eq!(
status, expected_status,
"Status code mismatch for error: {:?}",
error
);
}
}
}
Best Practices
- Use property tests for invariant checking
- Fuzz parsers and deserializers extensively
- Benchmark hot paths with Criterion
- Write contract tests for trait implementations
- Run Miri on unsafe code and concurrent code
- Table-driven tests for comprehensive coverage
- Regression tests for bugs found in production
- Integration tests with real dependencies (testcontainers)
Common Dependencies
[dev-dependencies]
proptest = "1"
criterion = { version = "0.5", features = ["html_reports"] }
testcontainers = "0.23"
[dependencies]
# For fuzz testing
arbitrary = { version = "1", optional = true, features = ["derive"] }
[features]
fuzzing = ["arbitrary"]
CI Integration
# Run all test suites in CI
- name: Unit tests
run: cargo test --workspace
- name: Property tests
run: cargo test --workspace -- --ignored proptest
- name: Miri (UB detection)
run: |
rustup component add miri
cargo miri test
- name: Benchmarks (smoke test)
run: cargo bench --no-run
- name: Fuzz (smoke test)
run: |
cargo install cargo-fuzz
timeout 60s cargo fuzz run parse_input || true