Property-Based Testing in Rust with proptest

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.txt

Configuration

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 test

Useful 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 types
  • prop_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

Read more