Property-Based Testing
Overview
Section titled “Overview”Property-based testing validates code properties hold for all inputs, not just specific examples. Uses proptest and quickcheck.
Philosophy: Instead of testing specific cases, test universal properties.
Traditional vs Property-Based
Section titled “Traditional vs Property-Based”Traditional (Example-Based)
Section titled “Traditional (Example-Based)”#[test]fn test_reverse() { assert_eq!(reverse("hello"), "olleh"); assert_eq!(reverse("rust"), "tsur"); assert_eq!(reverse(""), "");}Coverage: 3 specific cases
Property-Based
Section titled “Property-Based”use proptest::prelude::*;
proptest! { #[test] fn reverse_inverts(s: String) { assert_eq!(reverse(&reverse(&s)), s); }}Coverage: Hundreds of generated strings
Add Dependencies
Section titled “Add Dependencies”[dev-dependencies]proptest = "1.5"quickcheck = "1.0"quickcheck_macros = "1.0"Using Proptest
Section titled “Using Proptest”Basic Property Test
Section titled “Basic Property Test”use proptest::prelude::*;
proptest! { #[test] fn test_addition_commutative(a: i32, b: i32) { // Property: a + b == b + a prop_assert_eq!(a + b, b + a); }
#[test] fn test_multiplication_associative(a: i32, b: i32, c: i32) { // Property: (a * b) * c == a * (b * c) prop_assert_eq!((a * b) * c, a * (b * c)); }}Custom Strategies
Section titled “Custom Strategies”Generate specific input types:
use proptest::prelude::*;
fn valid_email() -> impl Strategy<Value = String> { "[a-z]{1,20}@[a-z]{1,10}\\.[a-z]{2,3}" .prop_map(|s| s.to_string())}
proptest! { #[test] fn test_email_parser(email in valid_email()) { let parsed = parse_email(&email); prop_assert!(parsed.is_ok()); }}Constrained Inputs
Section titled “Constrained Inputs”proptest! { #[test] fn test_positive_division( a in 1..1000i32, // 1 to 999 b in 1..100i32 // 1 to 99 ) { let result = a / b; prop_assert!(result >= 0); prop_assert!(result <= a); }}Shrinking
Section titled “Shrinking”When a test fails, proptest shrinks the input to find minimal failing case:
proptest! { #[test] fn test_fails_large_numbers(n: u32) { prop_assert!(n < 1000); // Fails for n >= 1000 }}Output:
Test failed for input: n = 1000(shrunk from initial failure: n = 4294967295)Common Properties
Section titled “Common Properties”1. Idempotence
Section titled “1. Idempotence”Property: Applying operation twice = applying once
proptest! { #[test] fn sort_is_idempotent(mut vec: Vec<i32>) { vec.sort(); let first = vec.clone(); vec.sort(); prop_assert_eq!(vec, first); }}2. Round-Trip (Encode/Decode)
Section titled “2. Round-Trip (Encode/Decode)”Property: Decode(Encode(x)) == x
proptest! { #[test] fn serialize_roundtrip(data: MyStruct) { let bytes = serialize(&data); let decoded = deserialize(&bytes).unwrap(); prop_assert_eq!(data, decoded); }}3. Invariants
Section titled “3. Invariants”Property: Certain conditions always hold
proptest! { #[test] fn heap_maintains_max(ops: Vec<HeapOp>) { let mut heap = MaxHeap::new(); for op in ops { match op { HeapOp::Push(n) => heap.push(n), HeapOp::Pop => { heap.pop(); } } // Invariant: top element is maximum if let Some(top) = heap.peek() { for item in heap.iter() { prop_assert!(*top >= *item); } } } }}4. Commutativity
Section titled “4. Commutativity”Property: Order doesn’t matter
proptest! { #[test] fn set_insertion_commutative(a: i32, b: i32) { let mut set1 = HashSet::new(); set1.insert(a); set1.insert(b);
let mut set2 = HashSet::new(); set2.insert(b); set2.insert(a);
prop_assert_eq!(set1, set2); }}5. Associativity
Section titled “5. Associativity”Property: Grouping doesn’t matter
proptest! { #[test] fn string_concat_associative(a: String, b: String, c: String) { let left = format!("{}{}{}", a, b, c); let right = format!("{}{}{}", a, b, c); prop_assert_eq!(left, right); }}6. Monotonicity
Section titled “6. Monotonicity”Property: Output increases with input
proptest! { #[test] fn absolute_value_monotonic(a: i32, b: i32) { if a <= b { prop_assert!(a.abs() <= b.abs() || a.abs() >= b.abs()); } }}7. Oracle (Test Against Known Implementation)
Section titled “7. Oracle (Test Against Known Implementation)”proptest! { #[test] fn optimized_matches_reference(input: Vec<i32>) { let optimized = fast_sort(&input); let reference = input.clone().sorted(); prop_assert_eq!(optimized, reference); }}Using QuickCheck
Section titled “Using QuickCheck”Alternative to proptest with simpler API:
use quickcheck_macros::quickcheck;
#[quickcheck]fn reverse_reverse_is_identity(vec: Vec<i32>) -> bool { let mut v = vec.clone(); v.reverse(); v.reverse(); v == vec}
#[quickcheck]fn adding_zero_is_identity(n: i32) -> bool { n + 0 == n && 0 + n == n}Advanced Strategies
Section titled “Advanced Strategies”Composite Types
Section titled “Composite Types”#[derive(Debug, Clone)]struct User { id: u64, name: String, age: u8,}
fn user_strategy() -> impl Strategy<Value = User> { ( any::<u64>(), "[a-zA-Z]{1,20}", 1u8..120u8 ).prop_map(|(id, name, age)| User { id, name: name.to_string(), age, })}
proptest! { #[test] fn test_user_validation(user in user_strategy()) { prop_assert!(validate_user(&user).is_ok()); }}Recursive Structures
Section titled “Recursive Structures”#[derive(Debug, Clone, PartialEq)]enum Tree { Leaf(i32), Node(Box<Tree>, Box<Tree>),}
fn tree_strategy() -> impl Strategy<Value = Tree> { let leaf = any::<i32>().prop_map(Tree::Leaf); leaf.prop_recursive( 8, // max depth 256, // max nodes 10, // items per collection |inner| { (inner.clone(), inner).prop_map(|(l, r)| { Tree::Node(Box::new(l), Box::new(r)) }) }, )}Weighted Strategies
Section titled “Weighted Strategies”fn operation_strategy() -> impl Strategy<Value = Op> { prop_oneof![ 3 => Just(Op::Add), // 30% probability 3 => Just(Op::Remove), // 30% 2 => Just(Op::Clear), // 20% 2 => Just(Op::Reset), // 20% ]}Configuration
Section titled “Configuration”Number of Test Cases
Section titled “Number of Test Cases”proptest! { #![proptest_config(ProptestConfig::with_cases(10000))]
#[test] fn extensive_test(n: u64) { // Runs 10,000 test cases instead of default 256 }}Reproducible Failures
Section titled “Reproducible Failures”// Failed seed from test outputproptest! { #![proptest_config(ProptestConfig { rng_algorithm: RngAlgorithm::ChaCha, seed: Some([0xdeadbeef; 4]), ..Default::default() })]
#[test] fn reproducible_test(n: i32) { // Uses fixed seed for reproduction }}Best Practices
Section titled “Best Practices”1. Start Simple
Section titled “1. Start Simple”// Start with basic propertyproptest! { #[test] fn len_after_push(mut vec: Vec<i32>, n: i32) { let original_len = vec.len(); vec.push(n); prop_assert_eq!(vec.len(), original_len + 1); }}2. Test Properties, Not Implementation
Section titled “2. Test Properties, Not Implementation”// ❌ Bad - tests implementation detailsproptest! { #[test] fn uses_quicksort(vec: Vec<i32>) { assert!(is_using_quicksort(&vec)); }}
// ✅ Good - tests behaviorproptest! { #[test] fn result_is_sorted(vec: Vec<i32>) { let sorted = my_sort(&vec); prop_assert!(is_sorted(&sorted)); }}3. Combine with Example Tests
Section titled “3. Combine with Example Tests”// Specific edge cases#[test]fn test_empty_vec() { assert_eq!(process(&[]), vec![]);}
// General propertiesproptest! { #[test] fn output_length_matches(input: Vec<i32>) { prop_assert_eq!(process(&input).len(), input.len()); }}4. Use Preconditions
Section titled “4. Use Preconditions”proptest! { #[test] fn division_property(a: i32, b: i32) { prop_assume!(b != 0); // Skip if precondition fails let result = a / b; prop_assert_eq!(result * b, a - (a % b)); }}Common Patterns
Section titled “Common Patterns”Testing Parsers
Section titled “Testing Parsers”proptest! { #[test] fn parser_roundtrip(ast: AST) { let text = format_ast(&ast); let parsed = parse(&text).unwrap(); prop_assert_eq!(parsed, ast); }}Testing Data Structures
Section titled “Testing Data Structures”proptest! { #[test] fn map_operations(ops: Vec<MapOp>) { let mut map = MyMap::new(); let mut reference = HashMap::new();
for op in ops { match op { MapOp::Insert(k, v) => { map.insert(k, v); reference.insert(k, v); } MapOp::Remove(k) => { map.remove(&k); reference.remove(&k); } } }
prop_assert_eq!(map.len(), reference.len()); }}Testing Concurrent Code
Section titled “Testing Concurrent Code”proptest! { #[test] fn concurrent_counter(ops: Vec<CounterOp>) { let counter = Arc::new(AtomicUsize::new(0)); let handles: Vec<_> = ops.into_iter().map(|op| { let c = counter.clone(); thread::spawn(move || { match op { CounterOp::Inc => c.fetch_add(1, Ordering::SeqCst), CounterOp::Dec => c.fetch_sub(1, Ordering::SeqCst), } }) }).collect();
for h in handles { h.join().unwrap(); }
// Invariant: counter value is consistent prop_assert!(counter.load(Ordering::SeqCst) >= 0); }}Debugging Failures
Section titled “Debugging Failures”Reproduce Specific Case
Section titled “Reproduce Specific Case”#[test]fn debug_specific_case() { // From proptest failure message let input = vec![1, 2, 3]; assert!(my_function(&input));}Print Debugging
Section titled “Print Debugging”proptest! { #[test] fn debug_test(n: i32) { println!("Testing with n = {}", n); prop_assert!(n >= 0 || n < 0); }}Performance Considerations
Section titled “Performance Considerations”Property tests are slower than example tests:
- Run 100-1000+ cases vs 1-10 examples
- Generation overhead
Optimize:
- Use in CI, not on every save
- Reduce cases for fast feedback:
with_cases(100) - Increase cases for thorough testing:
with_cases(10000)