Property-Based Testing in Rust with proptest
Example-based tests check specific cases you've thought of. Property-based tests let the framework generate hundreds of random inputs and find cases you haven't thought of. For Rust, the proptest crate brings property-based testing with automatic failure shrinking into the standard cargo test workflow.
What Is Property-Based Testing?
Instead of writing:
#[test]
fn test_reverse() {
assert_eq!(reverse(vec![1, 2, 3]), vec![3, 2, 1]);
}You write a property that must hold for all valid inputs:
use proptest::prelude::*;
proptest! {
#[test]
fn reverse_twice_is_identity(v in prop::collection::vec(any::<i32>(), 0..100)) {
let result = reverse(reverse(v.clone()));
prop_assert_eq!(result, v);
}
}proptest generates hundreds of random Vec<i32> values, runs the assertion for each, and if any fails, automatically shrinks the input to the smallest failing example.
Installation
Add proptest to Cargo.toml:
[dev-dependencies]
proptest = "1"The proptest! Macro
The core API is the proptest! macro, which works alongside standard #[test] functions:
use proptest::prelude::*;
proptest! {
#[test]
fn test_addition_is_commutative(a in 0i32..1000, b in 0i32..1000) {
prop_assert_eq!(a + b, b + a);
}
}Each parameter after in is a strategy — a description of what values to generate. 0i32..1000 is a range strategy that produces integers between 0 and 999.
prop_assert! vs assert!
Inside proptest!, use prop_assert! and prop_assert_eq! instead of their standard counterparts. Standard assert! panics unconditionally; prop_assert! returns a TestCaseError that proptest handles gracefully, enabling proper shrinking.
Built-in Strategies
proptest ships with strategies for every primitive type and many standard collections.
Numeric Strategies
proptest! {
#[test]
fn test_numeric(
a in any::<u8>(), // any u8 value
b in 0u32..100u32, // range
c in -50i64..50i64, // signed range
d in 1.0f64..10.0f64, // float range
) {
// ...
}
}String Strategies
proptest! {
#[test]
fn test_string(
s in ".*", // any string matching regex
email in r"[a-z]+@[a-z]+\.[a-z]{2,4}", // email-like strings
name in "[A-Za-z]{1,50}", // bounded alphabetic string
) {
// ...
}
}Regex-based string generation is powerful — you can constrain inputs to any format your function accepts.
Collection Strategies
use proptest::collection::{vec, hash_map};
proptest! {
#[test]
fn test_collections(
v in vec(any::<i32>(), 0..50), // Vec<i32> of 0-49 elements
m in hash_map(any::<u32>(), any::<String>(), 0..10),
) {
// ...
}
}Custom Strategies with prop_compose!
For domain types, compose strategies using prop_compose!:
#[derive(Debug, Clone)]
struct User {
age: u8,
name: String,
}
prop_compose! {
fn valid_user()(
age in 18u8..120u8,
name in "[A-Za-z]{2,30}",
) -> User {
User { age, name }
}
}
proptest! {
#[test]
fn test_user_can_vote(user in valid_user()) {
assert!(user.age >= 18);
}
}prop_compose! creates a function that returns a strategy. This composes cleanly — you can build complex domain objects by nesting composed strategies.
The Strategy Trait and Arbitrary
Any type implementing proptest's Arbitrary trait can be used with any::<T>(). Derive it for your types:
use proptest::prelude::*;
use proptest_derive::Arbitrary;
#[derive(Debug, Clone, Arbitrary)]
struct Point {
x: f64,
y: f64,
}
proptest! {
#[test]
fn test_distance_non_negative(a in any::<Point>(), b in any::<Point>()) {
prop_assert!(distance(a, b) >= 0.0);
}
}Add the derive crate:
[dev-dependencies]
proptest = "1"
proptest-derive = "0.4"Filtering Inputs with prop_assume!
Sometimes you need inputs that satisfy a precondition:
proptest! {
#[test]
fn test_division(a in any::<i32>(), b in any::<i32>()) {
prop_assume!(b != 0);
let result = a / b;
prop_assert!(result.abs() <= a.abs());
}
}prop_assume! discards the current test case and generates a new one. Use it sparingly — heavy filtering slows generation significantly. When your precondition is complex, define a targeted strategy that only generates valid inputs instead.
Failure Shrinking
When proptest finds a failing case, it automatically shrinks the input to the smallest example that still fails:
thread 'test_reverse_idempotent' panicked at 'Test failed:
proptest found minimal failing case: v = [0, 1]
Original failure:
v = [47, -3, 91, 0, 1, 22, -8, 17]Shrinking works by applying a search strategy over the input space. It's built into every strategy — you get it for free. This is one of the biggest practical advantages of property-based testing: failures are already minimized when they land in your terminal.
Persisting Failures
proptest saves failing inputs to a proptest-regressions/ directory. On subsequent runs, it replays these cases before generating new ones. Commit this directory to your repository so CI catches known regressions:
proptest-regressions/
└── tests__my_test.txtConfiguration
Customize the number of test cases and other settings:
use proptest::test_runner::Config;
proptest! {
#![proptest_config(Config {
cases: 1000, // default is 256
max_shrink_iters: 10_000,
..Config::default()
})]
#[test]
fn test_heavy(v in vec(any::<u64>(), 0..200)) {
// runs 1000 times
}
}Or set globally via environment variables:
PROPTEST_CASES=500 cargo testUseful Properties to Test
Property-based testing shines for certain categories of invariants:
Roundtrip properties: Encode then decode should return the original.
prop_assert_eq!(deserialize(serialize(input)), input);Commutativity and associativity: Order shouldn't matter.
prop_assert_eq!(a + b, b + a);
prop_assert_eq!((a + b) + c, a + (b + c));Monotonicity: Sorting a sorted list is a no-op.
let sorted = sort(v.clone());
prop_assert_eq!(sort(sorted.clone()), sorted);Boundary preservation: A function operating on a bounded set should return values in the same set.
Size invariants: Filtering never increases collection size.
Integration with Standard Tests
proptest functions integrate naturally with cargo test. You can mix example-based and property-based tests in the same #[cfg(test)] module:
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn specific_edge_case() {
assert_eq!(parse(""), None);
}
proptest! {
#[test]
fn fuzz_parser(s in "[a-z0-9_]{1,64}") {
// should never panic
let _ = parse(&s);
}
}
}Runtime Testing with HelpMeTest
Property-based tests catch input-space bugs at compile time. For production monitoring — catching regressions, API drift, and behavioral changes in deployed code — HelpMeTest runs continuous tests against live endpoints with no source code access required.
Summary
proptest!generates random inputs and shrinks failures automatically- Use range, regex, and collection strategies for primitive types
prop_compose!builds domain-specific strategies for your typesprop_assume!filters invalid inputs; prefer targeted strategies where possible- Commit
proptest-regressions/so CI replays known failures - Mix property tests with example tests in the same test module