Most developers rely on unit tests to catch bugs, but they only cover the cases you think to write. Property-based testing flips that approach: you define rules about how your code should behave, and a tool generates hundreds of test cases automatically to find the ones that break. Property-based testing: a practical guide for developers shows you how to transform your confidence in code correctness from "I tested these cases" to "I tested all the cases that matter."
What is property-based testing?
Property-based testing is a testing methodology where you specify invariants—properties that should always hold true—rather than examples. Instead of writing test case after test case manually, you describe what should be true about your function's output given any valid input, and the framework generates hundreds or thousands of inputs to verify that property holds.
In the Rust ecosystem, proptest is the dominant library for this. It generates random inputs, runs your code against them, and when a failure occurs, it shrinks the input down to the smallest possible reproduction case. This makes debugging far simpler than chasing down failures in a randomly generated 100-element vector.
The pattern is familiar: you write a test function, annotate it with a macro, define a strategy for input generation, and make assertions about the output. The framework does the heavy lifting.
How proptest generates inputs
The power of property-based testing starts with input generation. proptest provides a rich set of strategies that define the space of possible inputs. You don't have to enumerate cases; you describe the shape of valid data.
Here's a simple example: testing a function that parses and validates an email address.
use proptest::proptest;
fn is_valid_email(s: &str) -> bool {
s.contains('@') && s.len() > 3
}
proptest! {
#[test]
fn test_email_validation_basic(s in ".*@.*") {
let result = is_valid_email(&s);
// Any string with @ should be valid
assert!(result);
}
}
The strategy ".*@.*" is a regex-based generator. proptest will generate thousands of strings matching that pattern. But you can also use structured strategies:
use proptest::string::string_regex;
use proptest::proptest;
proptest! {
#[test]
fn test_email_with_strategy(
local in "[a-zA-Z0-9._%+-]+",
domain in "[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"
) {
let email = format!("{}@{}", local, domain);
let result = is_valid_email(&email);
assert!(result);
}
}
This separates the local and domain parts, giving you more control. You can combine strategies to build complex inputs: prop_oneof! for choosing between alternatives, prop::collection::vec for generating vectors with specific element strategies, and custom strategies for domain-specific types.
The key insight: you're describing valid input spaces, not enumerating test cases. This scales naturally as your code becomes more complex.
Shrinking failures to minimal cases
When a property fails on some generated input, proptest doesn't just tell you "your code failed on this 500-element vector with these specific values." Instead, it shrinks the input step by step, running the test each time, until it finds the smallest input that still triggers the failure. This minimal reproduction is invaluable.
Consider a function that processes integers in a sorted list:
fn process_sorted_list(nums: &[i32]) -> i32 {
// Should return sum of differences between consecutive elements
nums.windows(2).map(|w| (w[1] - w[0]).abs()).sum()
}
proptest! {
#[test]
fn prop_process_sorted(mut nums in prop::collection::vec(0i32..1000, 0..100)) {
nums.sort();
let result = process_sorted_list(&nums);
// Property: result should be non-negative
assert!(result >= 0);
}
}
If process_sorted_list has a bug with integer overflow on large numbers, proptest might generate a 47-element vector initially. When it finds the failure, it systematically removes elements, changes values, and reconstructs until it produces something like [0, 1000000] or a minimal vector that still fails. You get clarity instead of chaos.
The shrinking happens automatically for standard types. For custom types, you can implement the Shrink trait to teach the framework how to simplify your data structure meaningfully.
Parsers and mathematical functions: ideal use cases
Property-based testing shines on two categories of code: parsers and mathematical computations. Both have clear invariants that should hold regardless of input.
Parsers: A parser's correctness can be expressed as properties. If you parse a string and then serialize it back, you should get an equivalent structure. If you parse two different serializations of the same data, you should get equal results.
use proptest::proptest;
#[derive(Debug, Clone, Eq, PartialEq)]
struct Point { x: i32, y: i32 }
fn parse_point(s: &str) -> Result {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 2 {
return Err("Expected x,y format".into());
}
Ok(Point {
x: parts[0].trim().parse().map_err(|_| "Invalid x".to_string())?,
y: parts[1].trim().parse().map_err(|_| "Invalid y".to_string())?,
})
}
fn serialize_point(p: &Point) -> String {
format!("{},{}", p.x, p.y)
}
proptest! {
#[test]
fn prop_parse_roundtrip(p in (any::(), any::()).prop_map(|(x, y)| Point { x, y })) {
let serialized = serialize_point(&p);
let parsed = parse_point(&serialized).expect("Should parse");
assert_eq!(parsed, p);
}
}
Every valid point, when serialized and parsed, yields the original point. proptest explores thousands of coordinates instantly.
Mathematical functions: Properties like commutativity, associativity, and distributivity are natural invariants.
fn add_vectors(a: &[i32], b: &[i32]) -> Vec {
a.iter().zip(b.iter()).map(|(x, y)| x + y).collect()
}
proptest! {
#[test]
fn prop_vector_add_commutative(
a in prop::collection::vec(any::(), 1..10),
b in prop::collection::vec(any::(), 1..10)
) {
if a.len() == b.len() {
assert_eq!(add_vectors(&a, &b), add_vectors(&b, &a));
}
}
}
This automatically verifies that vector addition is commutative for any valid input. No manual test case can match this coverage.
Combining property-based and unit tests
Property-based testing is powerful, but it's not a replacement for unit tests. They complement each other. Unit tests let you document specific behaviors and edge cases you know about. Properties catch the edge cases you didn't anticipate.
A balanced approach: write unit tests for key scenarios and boundary conditions, then write property tests for the invariants that should hold everywhere.
#[cfg(test)]
mod tests {
use super::*;
// Unit test: document a specific behavior
#[test]
fn test_empty_list() {
assert_eq!(process_sorted_list(&[]), 0);
}
// Unit test: document a known edge case
#[test]
fn test_single_element() {
assert_eq!(process_sorted_list(&[5]), 0);
}
// Property test: verify invariant across all inputs
proptest! {
#[test]
fn prop_always_non_negative(nums in prop::collection::vec(0i32..1000, 0..100)) {
let result = process_sorted_list(&nums);
assert!(result >= 0);
}
#[test]
fn prop_result_grows_with_spread(
a in 0i32..100,
b in 200i32..300
) {
let result1 = process_sorted_list(&[a, b]);
let result2 = process_sorted_list(&[a, a + 1]);
assert!(result1 >= result2);
}
}
}
The unit tests serve as documentation and verification of known cases. The property tests catch issues in untested regions of the input space.
Common mistakes
Writing properties that are too permissive: A property that's always true regardless of the code's correctness is useless. Avoid assertions that simply echo the input back. "For any vector, this function returns a vector" is not a useful property.
Ignoring shrinking output: When a test fails, run it again and pay attention to the shrunk input. The final minimal case is often far simpler than the initially generated one, and understanding why it fails is crucial.
Over-generating with unbounded strategies: Generating enormous vectors or deep recursive structures makes tests slow. Use bounded strategies like vec(strategy, 1..100) instead of open-ended ones. Property tests should run in seconds, not minutes.
Forgetting preconditions: Not every input is valid for every function. Use prop_assume! to skip cases that violate preconditions, rather than expecting the property to hold unconditionally.
proptest! {
#[test]
fn prop_divide_by_nonzero(a in any::(), b in 1i32..=i32::MAX) {
let result = a / b;
// Property: result should fit in i32 range
assert!(result <= i32::MAX && result >= i32::MIN);
}
}
Mocking or stubbing in property tests: Property tests work best against real implementations. If you're stubbing dependencies heavily, you're not testing the property of your actual code under realistic conditions.
Key takeaways
- Property-based testing generates hundreds of test cases automatically, catching edge cases you wouldn't write manually.
- Use
propteststrategies to describe input spaces: regex generators, structured combinators, and custom types scale to complex scenarios. - Shrinking reduces failures to minimal reproduction cases, making debugging fast and focused.
- Parsers and mathematical functions are ideal candidates: they have clear invariants that should hold across all inputs.
- Combine property tests with unit tests: properties verify invariants globally, while unit tests document specific behaviors and known edge cases.
- Keep generators bounded and properties meaningful; avoid tautologies that can't fail.
- Property-based testing transforms testing from "I covered these cases" to "I verified these rules hold everywhere."






Comments
Loading comments…