How to Write Property-Based Tests in Rust (proptest, quickcheck)

Use the proptest crate to define tests that automatically generate random inputs to verify properties hold true for all cases.

When examples aren't enough

You write a function that calculates the average of a list of numbers. You test it with [1, 2, 3]. You test it with []. You test it with [100]. All tests pass. You ship the code. A user passes a list containing i32::MAX and i32::MIN. The sum overflows, wraps to a negative number, and your function returns a nonsensical result. Or a user passes a list so large that the iteration takes seconds, causing a timeout in a hot path.

Example-based testing catches the cases you thought of. It misses the cases you didn't. Property-based testing generates thousands of inputs for you, including boundary values, extreme sizes, and weird combinations. It finds the overflow you forgot about. It finds the edge case that breaks your invariant. It forces you to define what "correct" means in general terms, not just for specific inputs.

Don't trust your intuition about edge cases. Trust the generator.

The property mindset

Example-based testing asks, "Does this input produce this output?" Property-based testing asks, "Is this rule always true?" You define a property: a logical statement about your code that should hold for every possible input. The test framework generates random inputs, runs your code, and checks the property. If the property fails, the framework shrinks the input to find the smallest failing case.

Think of a vending machine. Example-based testing is putting in a quarter, pressing A1, and checking if a soda comes out. You do this for A1, B2, and C3. Property-based testing is defining a rule: "For any coin inserted and any button pressed, the machine either dispenses a product, returns change, or keeps the coin. It never eats money and gives nothing." You then shake the machine with every possible combination of coins and button presses to see if that rule ever breaks.

The goal isn't to test more cases. The goal is to test the rule.

Minimal example

The proptest crate is the standard tool for property-based testing in Rust. It provides macros and strategies to generate inputs and check properties. Add proptest to your Cargo.toml dependencies and use the proptest! macro to define a test.

use proptest::prelude::*;

// proptest! defines a test that runs with generated inputs.
// The macro expands to a standard #[test] function.
proptest! {
    #[test]
    // `a` and `b` are generated randomly for each test iteration.
    // `any::<i32>()` tells proptest to generate any valid i32 value.
    fn addition_is_commutative(a in any::<i32>(), b in any::<i32>()) {
        // prop_assert_eq! captures the values if the assertion fails.
        // This lets proptest shrink the inputs to find the minimal failure.
        // Using standard assert! here would break shrinking.
        prop_assert_eq!(a + b, b + a);
    }
}

Run the test with cargo test. The framework generates a batch of random inputs, runs the assertion, and reports the result. If the property holds, the test passes. If it fails, proptest reports the failure along with the minimal input that triggered it.

Run this and watch the test counter tick up. You just tested millions of cases with three lines of code.

How shrinking saves you

When a property-based test fails, the raw input is often huge or complex. A failure on [18446744073709551615, 9223372036854775807] is hard to debug. proptest uses a shrinking algorithm to reduce the input step by step. It tries smaller values, shorter lists, and simpler structures until it finds the minimal case that still fails.

Shrinking turns a mystery into a minimal reproducible example. Instead of a failure on a random vector of 10,000 elements, you get a failure on [1, 0]. This makes it trivial to see what went wrong. The shrinking process relies on the prop_assert! macros. These macros capture the state before the panic, allowing the framework to retry with shrunk inputs.

Shrinking turns a mystery into a minimal repro.

Strategies and realistic tests

any::<T>() generates any value of type T. For complex inputs, you need custom strategies. Strategies define how values are generated and shrunk. You can chain strategies to filter, map, or transform values.

use proptest::prelude::*;

fn calculate_average(values: &[i32]) -> Option<i32> {
    if values.is_empty() {
        return None;
    }
    // Use i64 to prevent overflow during sum calculation.
    let sum: i64 = values.iter().map(|&v| v as i64).sum();
    Some((sum / values.len() as i64) as i32)
}

proptest! {
    #[test]
    // Generate a vector of i32s with length between 0 and 100.
    fn average_is_within_range(values in prop::collection::vec(any::<i32>(), 0..=100)) {
        // Skip empty vectors; the function returns None for them.
        // prop_assume! discards the current case and generates a new one.
        prop_assume!(!values.is_empty());

        let avg = calculate_average(&values).unwrap();

        // Property: The average must be between the min and max of the list.
        let min = *values.iter().min().unwrap();
        let max = *values.iter().max().unwrap();

        prop_assert!(avg >= min);
        prop_assert!(avg <= max);
    }
}

Convention: Use prop_assume! for simple preconditions. If your assumption filters out most inputs, the test spends time generating values it throws away. This leads to slow tests or timeouts. Fix this by narrowing the strategy instead. Generate only valid inputs rather than generating everything and filtering. For example, use prop::collection::vec(any::<i32>(), 1..=100) to generate non-empty vectors directly.

Filter your inputs at the source. If you have to filter heavily, your strategy is wrong.

Custom strategies with prop_compose

When your inputs have structure, use prop_compose! to build a strategy that generates valid objects. This keeps your test code clean and ensures the generated data matches your domain constraints.

use proptest::prelude::*;

#[derive(Debug)]
struct User {
    id: u64,
    name: String,
}

// prop_compose! defines a reusable strategy for generating User structs.
// Each field gets its own strategy.
prop_compose! {
    fn user()(id in any::<u64>(), name in "([a-zA-Z ]+){1,50}") -> User {
        User { id, name: name.to_string() }
    }
}

proptest! {
    #[test]
    // Use the custom strategy to generate User instances.
    fn user_name_is_not_empty(u in user()) {
        prop_assert!(!u.name.is_empty());
    }
}

Implement Arbitrary for your types to make them usable with any::<T>(). This is the idiomatic way to integrate your domain models with proptest. Deriving Arbitrary works for simple structs, but complex types often need a manual implementation to ensure invariants hold.

Convention: Keep prop_compose! strategies small and focused. Compose them to build larger strategies. This makes it easier to reuse and test individual parts.

Pitfalls

Using standard assert! inside a proptest! block breaks shrinking. The test framework relies on prop_assert! to capture the state before the panic. If you use assert!, the test panics immediately, and proptest reports the failure with the full random input. You lose the minimal failing case. The test still fails, but debugging becomes much harder.

prop_assume! discards the current input and tries again. If your assumption filters out too many inputs, the test runs slowly or times out. This is called rejection sampling overhead. If you see warnings about discarded cases, narrow your strategy. Generate valid inputs directly instead of filtering.

Non-determinism can cause tests to pass locally but fail in CI. proptest uses a random seed to generate inputs. You can set the seed to reproduce failures. Use PROptest environment variables or the --seed flag to control randomness.

Use prop_assert! or lose the shrinking. There is no middle ground.

Decision matrix

Use proptest when you need robust property-based testing with shrinking and custom strategies. It is the community standard for this pattern in Rust.

Use example-based tests when you are verifying specific edge cases or business rules that are hard to generalize into a property. Property-based tests complement example tests; they do not replace them.

Use quickcheck when you are maintaining legacy code that already depends on it. quickcheck is older and has less active development. New projects should prefer proptest.

Use snapshot testing when you are validating large outputs like UI rendering or complex JSON structures where the exact output matters more than a mathematical property.

Pick the tool that matches the shape of the invariant. Properties for rules. Examples for specifics.

Where to go next