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
rust
1use proptest::prelude::*;
2
3proptest! {
4 /// Property: Serialization round-trip preserves data
5 #[test]
6 fn user_serialization_roundtrip(user in any::<User>()) {
7 let serialized = serde_json::to_string(&user)?;
8 let deserialized: User = serde_json::from_str(&serialized)?;
9 prop_assert_eq!(user, deserialized);
10 }
11
12 /// Property: Email validation accepts valid emails
13 #[test]
14 fn email_validation_accepts_valid_emails(
15 email in r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
16 ) {
17 let result = Email::parse(&email);
18 prop_assert!(result.is_ok());
19 }
20
21 /// Property: Vec operations maintain size invariants
22 #[test]
23 fn vec_push_increases_len(
24 mut vec in prop::collection::vec(any::<i32>(), 0..100),
25 value in any::<i32>()
26 ) {
27 let original_len = vec.len();
28 vec.push(value);
29 prop_assert_eq!(vec.len(), original_len + 1);
30 }
31}
Advanced Property Tests
rust
1proptest! {
2 /// Property: Account balance invariants
3 #[test]
4 fn account_balance_invariants(
5 initial_balance in 0u64..1_000_000,
6 transactions in prop::collection::vec(
7 prop::oneof![
8 (1u64..10_000).prop_map(Transaction::Deposit),
9 (1u64..10_000).prop_map(Transaction::Withdrawal),
10 ],
11 1..100
12 )
13 ) {
14 let mut account = Account::new(initial_balance);
15 let mut expected_balance = initial_balance;
16
17 for transaction in transactions {
18 match transaction {
19 Transaction::Deposit(amount) => {
20 account.deposit(amount)?;
21 expected_balance += amount;
22 }
23 Transaction::Withdrawal(amount) => {
24 if account.balance() >= amount {
25 account.withdraw(amount)?;
26 expected_balance -= amount;
27 }
28 }
29 }
30
31 // Invariant: balance must always be non-negative
32 prop_assert!(account.balance() >= 0);
33 prop_assert_eq!(account.balance(), expected_balance);
34 }
35 }
36
37 /// Property: Sorted vector stays sorted after insertion
38 #[test]
39 fn sorted_insert_maintains_order(
40 mut sorted_vec in prop::collection::vec(any::<i32>(), 0..100)
41 .prop_map(|mut v| { v.sort(); v }),
42 value in any::<i32>()
43 ) {
44 sorted_vec.insert(
45 sorted_vec.binary_search(&value).unwrap_or_else(|i| i),
46 value
47 );
48
49 // Verify still sorted
50 for i in 1..sorted_vec.len() {
51 prop_assert!(sorted_vec[i - 1] <= sorted_vec[i]);
52 }
53 }
54}
Custom Generators
rust
1use proptest::strategy::{Strategy, BoxedStrategy};
2
3/// Custom strategy for generating valid users
4fn arb_user() -> BoxedStrategy<User> {
5 (
6 r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
7 r"[A-Z][a-z]+ [A-Z][a-z]+",
8 13u8..=120u8,
9 )
10 .prop_map(|(email, name, age)| {
11 User {
12 id: UserId::new(),
13 email,
14 name,
15 age,
16 created_at: chrono::Utc::now(),
17 }
18 })
19 .boxed()
20}
21
22proptest! {
23 #[test]
24 fn test_with_valid_users(user in arb_user()) {
25 // Test only runs with valid user data
26 prop_assert!(user.age >= 13 && user.age <= 120);
27 prop_assert!(user.email.contains('@'));
28 }
29}
Fuzz Testing
Basic Fuzz Target
rust
1// fuzz/fuzz_targets/parse_input.rs
2#![no_main]
3use libfuzzer_sys::fuzz_target;
4
5fuzz_target!(|data: &[u8]| {
6 // Should never panic on arbitrary input
7 if let Ok(s) = std::str::from_utf8(data) {
8 let _ = parse_user_input(s);
9 }
10});
Structured Fuzzing
rust
1// fuzz/fuzz_targets/api_request.rs
2#![no_main]
3use libfuzzer_sys::fuzz_target;
4use arbitrary::Arbitrary;
5
6#[derive(Debug, Arbitrary)]
7struct FuzzApiRequest {
8 method: String,
9 path: String,
10 headers: Vec<(String, String)>,
11 body: Vec<u8>,
12}
13
14fuzz_target!(|req: FuzzApiRequest| {
15 // Test API handler doesn't panic on arbitrary inputs
16 let _ = handle_request(
17 req.method,
18 req.path,
19 req.headers,
20 req.body,
21 );
22});
Regression Tests from Fuzzing
rust
1#[cfg(test)]
2mod fuzz_regression_tests {
3 use super::*;
4
5 /// Edge cases discovered by fuzzing
6 #[test]
7 fn test_known_edge_cases() {
8 let edge_cases = vec![
9 "", // Empty input
10 "\0", // Null byte
11 "🦀", // Unicode
12 &"x".repeat(10_000), // Large input
13 "\n\r\t", // Whitespace
14 "{{{{", // Unbalanced braces
15 ];
16
17 for case in edge_cases {
18 // Should handle gracefully without panicking
19 let result = parse_user_input(case);
20 assert!(result.is_ok() || result.is_err());
21 }
22 }
23}
Benchmark Testing
Basic Criterion Benchmarks
rust
1use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
2
3fn benchmark_user_operations(c: &mut Criterion) {
4 let mut group = c.benchmark_group("user_operations");
5
6 // Benchmark with different input sizes
7 for size in [10, 100, 1000, 10000].iter() {
8 group.bench_with_input(
9 BenchmarkId::new("lookup", size),
10 size,
11 |b, &size| {
12 let users = generate_test_users(size);
13 b.iter(|| {
14 let id = &users[rand::random::<usize>() % users.len()].id;
15 black_box(lookup_user(black_box(id)))
16 })
17 },
18 );
19 }
20
21 group.finish();
22}
23
24criterion_group!(benches, benchmark_user_operations);
25criterion_main!(benches);
Advanced Benchmarking
rust
1use criterion::{Criterion, BenchmarkId, Throughput};
2use std::time::Duration;
3
4fn benchmark_serialization(c: &mut Criterion) {
5 let mut group = c.benchmark_group("serialization");
6
7 // Configure statistical parameters
8 group.sample_size(100);
9 group.measurement_time(Duration::from_secs(10));
10 group.confidence_level(0.95);
11
12 for size in [10, 100, 1000].iter() {
13 let data = generate_data(*size);
14
15 // Set throughput for bytes per second calculation
16 group.throughput(Throughput::Bytes(data.len() as u64));
17
18 group.bench_with_input(
19 BenchmarkId::new("json", size),
20 &data,
21 |b, data| {
22 b.iter(|| {
23 let serialized = serde_json::to_string(black_box(data)).unwrap();
24 black_box(serialized)
25 })
26 },
27 );
28
29 group.bench_with_input(
30 BenchmarkId::new("bincode", size),
31 &data,
32 |b, data| {
33 b.iter(|| {
34 let serialized = bincode::serialize(black_box(data)).unwrap();
35 black_box(serialized)
36 })
37 },
38 );
39 }
40
41 group.finish();
42}
Allocation Benchmarking
rust
1#[cfg(test)]
2mod allocation_tests {
3 use super::*;
4
5 #[test]
6 fn test_zero_allocation_path() {
7 let allocations_before = allocation_counter::current();
8
9 // Critical path that should not allocate
10 let result = process_request_zero_alloc(&input);
11
12 let allocations_after = allocation_counter::current();
13 let total_allocations = allocations_after - allocations_before;
14
15 assert_eq!(
16 total_allocations, 0,
17 "Critical path allocated {} bytes",
18 total_allocations
19 );
20 }
21}
Contract Testing
Trait Contract Tests
rust
1use async_trait::async_trait;
2
3#[async_trait]
4pub trait UserRepository: Send + Sync {
5 async fn get_user(&self, id: UserId) -> Result<User, RepositoryError>;
6 async fn save_user(&self, user: &User) -> Result<(), RepositoryError>;
7}
8
9/// Contract tests that all implementations must satisfy
10#[cfg(test)]
11pub mod contract_tests {
12 use super::*;
13
14 pub async fn test_user_repository_contract<R: UserRepository>(repo: R) {
15 // Test: Save and retrieve should be consistent
16 let user = User::new("test@example.com".to_string(), "Test User".to_string());
17
18 repo.save_user(&user).await.unwrap();
19 let retrieved = repo.get_user(user.id).await.unwrap();
20
21 assert_eq!(user.id, retrieved.id);
22 assert_eq!(user.email, retrieved.email);
23 assert_eq!(user.name, retrieved.name);
24 }
25
26 pub async fn test_user_repository_not_found<R: UserRepository>(repo: R) {
27 // Test: Getting non-existent user should return error
28 let non_existent_id = UserId::new();
29 let result = repo.get_user(non_existent_id).await;
30
31 assert!(matches!(result, Err(RepositoryError::NotFound)));
32 }
33}
34
35/// Apply contract tests to concrete implementation
36#[tokio::test]
37async fn postgres_repository_satisfies_contract() {
38 let repo = PostgresUserRepository::new(get_test_db().await);
39 contract_tests::test_user_repository_contract(repo.clone()).await;
40 contract_tests::test_user_repository_not_found(repo).await;
41}
42
43#[tokio::test]
44async fn in_memory_repository_satisfies_contract() {
45 let repo = InMemoryUserRepository::new();
46 contract_tests::test_user_repository_contract(repo.clone()).await;
47 contract_tests::test_user_repository_not_found(repo).await;
48}
Miri for Undefined Behavior Detection
Using Miri
bash
1# Install Miri
2rustup +nightly component add miri
3
4# Run tests with Miri
5cargo +nightly miri test
6
7# Run specific test
8cargo +nightly miri test test_concurrent_access
Miri-Compatible Tests
rust
1#[cfg(test)]
2mod miri_tests {
3 use super::*;
4
5 #[test]
6 fn test_safe_concurrent_access() {
7 use std::sync::Arc;
8 use std::thread;
9
10 let counter = Arc::new(AtomicCounter::new());
11 let mut handles = vec![];
12
13 for _ in 0..10 {
14 let counter_clone = counter.clone();
15 handles.push(thread::spawn(move || {
16 for _ in 0..100 {
17 counter_clone.increment();
18 }
19 }));
20 }
21
22 for handle in handles {
23 handle.join().unwrap();
24 }
25
26 assert_eq!(counter.get(), 1000);
27 }
28}
Table-Driven Tests
Data-Driven Test Cases
rust
1#[cfg(test)]
2mod table_driven_tests {
3 use super::*;
4
5 #[test]
6 fn test_email_validation() {
7 let test_cases = vec![
8 ("test@example.com", true),
9 ("user+tag@domain.co.uk", true),
10 ("invalid.email", false),
11 ("@example.com", false),
12 ("user@", false),
13 ("", false),
14 ];
15
16 for (input, expected_valid) in test_cases {
17 let result = Email::parse(input);
18 assert_eq!(
19 result.is_ok(),
20 expected_valid,
21 "Email validation failed for: {}",
22 input
23 );
24 }
25 }
26
27 #[test]
28 fn test_status_code_mapping() {
29 let test_cases = vec![
30 (ApiError::ValidationError(_), StatusCode::BAD_REQUEST),
31 (ApiError::Unauthorized, StatusCode::UNAUTHORIZED),
32 (ApiError::Forbidden, StatusCode::FORBIDDEN),
33 (ApiError::NotFound, StatusCode::NOT_FOUND),
34 (ApiError::InternalError, StatusCode::INTERNAL_SERVER_ERROR),
35 ];
36
37 for (error, expected_status) in test_cases {
38 let status = error.status_code();
39 assert_eq!(
40 status, expected_status,
41 "Status code mismatch for error: {:?}",
42 error
43 );
44 }
45 }
46}
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
toml
1[dev-dependencies]
2proptest = "1"
3criterion = { version = "0.5", features = ["html_reports"] }
4testcontainers = "0.23"
5
6[dependencies]
7# For fuzz testing
8arbitrary = { version = "1", optional = true, features = ["derive"] }
9
10[features]
11fuzzing = ["arbitrary"]
CI Integration
yaml
1# Run all test suites in CI
2- name: Unit tests
3 run: cargo test --workspace
4
5- name: Property tests
6 run: cargo test --workspace -- --ignored proptest
7
8- name: Miri (UB detection)
9 run: |
10 rustup component add miri
11 cargo miri test
12
13- name: Benchmarks (smoke test)
14 run: cargo bench --no-run
15
16- name: Fuzz (smoke test)
17 run: |
18 cargo install cargo-fuzz
19 timeout 60s cargo fuzz run parse_input || true