Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Property-Based Testing

🔧 HOW-TO | 📚 REFERENCE | Generate random test data to verify properties

Property-based testing generates random test data and verifies that properties hold for all inputs.

What is a Property?

A property is a logical assertion that should hold for all valid inputs:

#![allow(unused)]
fn main() {
// Property: Addition is commutative
// For all a, b: a + b == b + a

// Property: Parsing and formatting is round-trip safe
// For all x: parse(format(x)) == x

// Property: Sorted list has no inversions
// For all lists: list[i] <= list[i+1]
}

Property-Based vs. Example-Based

Example-Based (Traditional)

#![allow(unused)]
fn main() {
test!(test_addition_examples, {
    assert_eq!(2 + 3, 5);
    assert_eq!(0 + 5, 5);
    assert_eq!(10 + 0, 10);
});
}

Limitation: Only tests specific examples. What about u32::MAX + 1?

Property-Based

#![allow(unused)]
fn main() {
test!(test_addition_property, {
    let strategy = ProptestStrategy::new().with_cases(1000);
    strategy.test(any::<(u32, u32)>(), |(a, b)| {
        a + b == b + a  // Checks for 1000 random pairs
    });
});
}

Advantage: Tests 1000 random cases automatically.

Getting Started

Basic Property Test

#![allow(unused)]
fn main() {
use chicago_tdd_tools::property::*;
use proptest::prelude::*;

test!(test_parsing_property, {
    let strategy = ProptestStrategy::new().with_cases(100);

    strategy.test(any::<u32>(), |num| {
        let formatted = format!("{}", num);
        let parsed: u32 = formatted.parse().unwrap();
        num == parsed  // Property: round-trip works
    });
});
}

Using Generators

Generate specific types:

#![allow(unused)]
fn main() {
test!(test_string_properties, {
    let strategy = ProptestStrategy::new().with_cases(100);

    // Test with strings of 1-100 characters
    strategy.test("[a-zA-Z0-9]{1,100}", |s| {
        // Property: non-empty string remains non-empty
        !s.is_empty()
    });
});
}

Common Properties to Test

1. Commutativity

#![allow(unused)]
fn main() {
// Property: a + b == b + a
strategy.test(any::<(i32, i32)>(), |(a, b)| {
    a + b == b + a
});
}

2. Associativity

#![allow(unused)]
fn main() {
// Property: (a + b) + c == a + (b + c)
strategy.test(any::<(i32, i32, i32)>(), |(a, b, c)| {
    (a + b) + c == a + (b + c)
});
}

3. Distributivity

#![allow(unused)]
fn main() {
// Property: a * (b + c) == (a * b) + (a * c)
strategy.test(any::<(i32, i32, i32)>(), |(a, b, c)| {
    a * (b + c) == (a * b) + (a * c)
});
}

4. Identity

#![allow(unused)]
fn main() {
// Property: a + 0 == a
strategy.test(any::<i32>(), |a| {
    a + 0 == a
});
}

5. Inverse

#![allow(unused)]
fn main() {
// Property: a - a == 0
strategy.test(any::<i32>(), |a| {
    a - a == 0
});
}

Real-World Example: JSON Parsing

#![allow(unused)]
fn main() {
test!(test_json_parsing_properties, {
    let strategy = ProptestStrategy::new().with_cases(500);

    strategy.test(any::<(String, i32, bool)>(), |(name, age, active)| {
        // Create JSON
        let json = format!(
            r#"{{"name":"{}","age":{},"active":{}}}"#,
            name, age, active
        );

        // Parse it
        let parsed: Result<MyData, _> = serde_json::from_str(&json);

        // Property: Valid input parses successfully
        parsed.is_ok()
    });
});
}

Real-World Example: String Validation

#![allow(unused)]
fn main() {
test!(test_email_validation, {
    let strategy = ProptestStrategy::new().with_cases(200);

    // Test valid emails
    strategy.test(
        r"[a-zA-Z0-9]+@[a-zA-Z0-9]+\.[a-zA-Z]{2,}",
        |email| {
            // Property: Valid email passes validation
            validate_email(email).is_ok()
        }
    );
});
}

Shrinking

When a property fails, shrinking finds the minimal failing case:

#![allow(unused)]
fn main() {
test!(test_with_shrinking, {
    let strategy = ProptestStrategy::new().with_cases(100);

    strategy.test(any::<Vec<i32>>(), |vec| {
        // Property fails for some input
        vec.len() < 10  // This might fail for vec![1,2,3,...,100]

        // Shrinking finds minimal failure: vec with length >= 10
    });
});
}

Combining Strategies

Test multiple values together:

#![allow(unused)]
fn main() {
test!(test_combined_strategy, {
    let strategy = ProptestStrategy::new().with_cases(100);

    // Test with tuple of (String, u32, bool)
    strategy.test(
        (any::<String>(), 1u32..100u32, any::<bool>()),
        |(name, age, active)| {
            // Test with all three values
            !name.is_empty() && age > 0
        }
    );
});
}

Configuration

Number of Cases

#![allow(unused)]
fn main() {
// Test with 100 random cases
let strategy = ProptestStrategy::new().with_cases(100);

// Test with 1000 cases (slower, more thorough)
let strategy = ProptestStrategy::new().with_cases(1000);

// Test with 10 cases (faster, less thorough)
let strategy = ProptestStrategy::new().with_cases(10);
}

Random Seed

For reproducible tests:

#![allow(unused)]
fn main() {
let mut generator = PropertyTestGenerator::<100, 5>::new()
    .with_seed(42);  // Use specific seed

let data = generator.generate_test_data();
}

When to Use Property-Based Testing

Use for:

  • Mathematical properties (commutativity, associativity)
  • Round-trip properties (serialize/deserialize)
  • Parsing and formatting
  • List operations (sort, filter, map)
  • State machine transitions

Don't use for:

  • Specific business logic (use example tests)
  • Performance testing (use benchmarks)
  • Complex setup (use fixtures)

Best Practices

Do:

  • Test actual properties (not specific values)
  • Use meaningful generators
  • Start with 100-500 cases
  • Check edge cases manually

Don't:

  • Replace example tests (both have value)
  • Use excessive cases (slows down tests)
  • Ignore failed cases
  • Only use randomly generated data

Performance

  • 100 cases: ~100ms per property
  • 1000 cases: ~1s per property
  • 10,000 cases: ~10s per property

Recommendation: Start with 100-500 cases. Use more for critical code.

Troubleshooting

Property Fails Intermittently

Use shrinking output to find minimal case:

#![allow(unused)]
fn main() {
test!(test_debug_failure, {
    let strategy = ProptestStrategy::new().with_cases(1000);
    strategy.test(any::<(u32, u32)>(), |(a, b)| {
        // If fails: check shrunk output
        // Example: shrunk to (0, 0) or (u32::MAX, 0)
        (a as u64) + (b as u64) < u64::MAX
    });
});
}

Property Too Strict

Relax constraints:

#![allow(unused)]
fn main() {
// Too strict: a * b == b * a (fails due to overflow)
// Better: a.checked_mul(b) == b.checked_mul(a)
}

Next Steps

Learn mutation testing: Mutation Testing


Summary

Property-based testing:

  • ✅ Tests properties for all inputs
  • ✅ Finds edge cases automatically
  • ✅ Includes shrinking to find minimal failures
  • ✅ Great for algorithms and parsing

Use with fixtures and data builders for comprehensive testing.