How to use proptest crate in Rust property testing

Use proptest by adding it as a dev-dependency and writing #[test] functions with proptest! macros to automatically verify logic against random inputs.

When examples stop being enough

You write a function that parses a configuration string. You test it with three examples: a valid path, an empty string, and a missing key. All three pass. You ship it. Production sends a path with escaped quotes, a trailing comma, and a UTF-8 surrogate pair. Your parser panics. Example-based testing only covers the cases you imagined. Property testing covers the cases you did not.

Instead of hardcoding inputs and expected outputs, you describe rules that must always hold. Addition is commutative. Reversing a list twice returns the original. Sorting preserves length. You hand the inputs to a testing harness that generates thousands of random values, checks the rule, and stops the moment it finds a violation. It then shrinks that violation down to the smallest possible failing input. Think of it like a structural engineer who does not just inspect a few beams, but stress-tests the entire blueprint against a list of physical laws.

Property testing in plain words

Traditional tests ask a single question: does this specific input produce this specific output? Property tests ask a broader question: does this rule hold for every possible input in a given domain?

The proptest crate handles the heavy lifting. You provide a strategy, which is a recipe for generating random values of a certain type. You provide a closure that asserts a property holds for those values. proptest runs the closure hundreds of times with different inputs. If every run succeeds, the test passes. If one run fails, proptest does not just print the giant random input that broke it. It runs a shrinking algorithm to find the minimal input that still triggers the failure. You get a tiny, reproducible test case that you can paste directly into a standard #[test] function.

The mental shift is straightforward. You stop writing test cases. You start writing invariants. The machine does the combinatorial work.

Your first property test

Add proptest as a development dependency. The crate is only needed during testing, so it belongs in the [dev-dependencies] section of your manifest.

[dev-dependencies]
proptest = { version = "0.10", default-features = false, features = ["alloc"] }

Write a test function that uses the proptest! macro. The macro takes a pattern that binds generated values to variables, followed by a closure that runs your assertions.

use proptest::prelude::*;

/// Verifies that integer addition remains commutative across the full i32 range
#[test]
fn addition_is_commutative() {
    // The macro expands into a loop that runs the closure 256 times by default
    proptest!(|(a in any::<i32>(), b in any::<i32>())| {
        // prop_assert! catches failures and hands control back to proptest for shrinking
        prop_assert_eq!(a + b, b + a);
    });
}

Run it with cargo test. The output will show how many cases ran and whether any failed. The any::<i32>() expression is a strategy. It tells proptest to generate random 32-bit signed integers. The macro expands into a loop that runs the closure 256 times by default. Each iteration pulls fresh values from the strategies, binds them to a and b, and executes your assertion.

How shrinking saves you time

The magic happens when a test fails. Standard assertions panic immediately. proptest uses custom assertion macros like prop_assert_eq! because they catch the failure, record the inputs, and return control to the test runner. The runner then starts shrinking.

Shrinking is a systematic reduction of the failing input. If a test fails on a vector of one thousand elements, proptest tries removing elements one by one. If it fails on a string of fifty characters, it tries shorter substrings. If it fails on an integer of nine billion, it tries smaller numbers. The algorithm stops when it finds the smallest input that still triggers the bug.

This turns a mountain of randomness into a precise diagnostic. You do not get a wall of hex digits. You get the exact boundary condition that broke your logic. Paste that minimal case into a regular unit test, fix the bug, and move on.

Trust the shrinking algorithm. It finds edge cases faster than manual brainstorming.

Testing real code with custom strategies

Real functions rarely accept raw integers. They take structs, enums, or complex strings. proptest provides the Strategy trait and helper macros to compose custom generators. The prop_compose! macro lets you build a strategy that produces a struct, ensuring all fields stay in sync.

use proptest::prelude::*;

/// A simple configuration record for testing
#[derive(Debug, Clone)]
struct Config {
    max_retries: u32,
    timeout_ms: u32,
}

/// Generates valid Config values where timeout stays within a realistic range
prop_compose! {
    fn config_strategy()(max_retries in 1u32..10u32, timeout_ms in 100u32..5000u32) -> Config {
        Config { max_retries, timeout_ms }
    }
}

/// Calculates an exponential backoff delay based on retry count
fn backoff_delay(config: &Config, attempt: u32) -> u32 {
    let base = config.timeout_ms;
    // Simulate a bug: overflows if attempt is large enough
    base * 2u32.pow(attempt)
}

/// Verifies that backoff delays never exceed the configured timeout multiplied by retries
#[test]
fn backoff_stays_bounded() {
    proptest!(|(config in config_strategy(), attempt in 0u32..5u32)| {
        let delay = backoff_delay(&config, attempt);
        // The delay should logically stay under a reasonable cap
        prop_assert!(delay < config.timeout_ms * 100, "delay {} exceeded cap", delay);
    });
}

The prop_compose! macro guarantees that max_retries and timeout_ms always fall within their specified ranges. The test runner generates hundreds of valid Config structs and attempts. If the multiplication overflows or produces an unexpectedly large value, prop_assert! catches it. The shrinking phase will reduce attempt and timeout_ms until it finds the exact combination that breaks the bound.

Convention note: keep your strategies close to the types they generate. If a strategy spans multiple modules, extract it into a dedicated strategies module. This keeps your test files focused on assertions instead of generator plumbing.

Handling Results and early exits

Most real functions return Result<T, E>. Property testing requires a slight adjustment. You cannot assert on a Result directly because the test runner needs to know whether the input was valid or if the function legitimately returned an error. Use prop_assert! combined with pattern matching, or use the prop_assert_eq! macro on the expected error variant.

use proptest::prelude::*;

/// Parses a positive integer from a string, returning an error on invalid input
fn parse_positive(s: &str) -> Result<u32, &'static str> {
    s.parse::<u32>().map_err(|_| "parse failed")
        .and_then(|n| if n > 0 { Ok(n) } else { Err("must be positive") })
}

/// Verifies that parsing a valid number and converting it back to a string matches the original
#[test]
fn roundtrip_preserves_value() {
    proptest!(|(n in 1u32..1000u32)| {
        let s = n.to_string();
        let parsed = parse_positive(&s);
        // Only test inputs that the parser accepts
        prop_assert!(parsed.is_ok(), "parser rejected valid number {}", n);
        prop_assert_eq!(parsed.unwrap(), n);
    });
}

The prop_assert!(parsed.is_ok()) line acts as a guard. If the parser rejects a value that should be valid, the test fails and shrinking begins. If the guard passes, the second assertion verifies the roundtrip. This pattern keeps your properties focused on valid domains without cluttering them with error-handling logic.

Convention note: use let _ = … when you intentionally ignore a Result or a generated value. It signals to reviewers that you considered the output and chose to drop it.

Pitfalls and compiler friction

Property testing introduces a few new failure modes. The most common one involves mixing standard assertions with proptest macros. If you use assert_eq! inside a proptest! closure, the test panics before proptest can record the inputs or start shrinking. You will see a standard panic message with no generated values. Always use prop_assert!, prop_assert_eq!, or prop_assert_ne!. They are designed to work with the shrinking loop.

Type mismatches trigger E0277 (trait bound not satisfied) when a type does not implement the Arbitrary trait. proptest needs a strategy to generate values. If you pass a custom enum without deriving Arbitrary or providing a strategy, the compiler rejects it. Fix it by adding #[derive(Arbitrary)] to your struct or enum, or by writing a manual strategy with prop_oneof!.

use proptest::prelude::*;

#[derive(Debug, Arbitrary)] // Generates random variants automatically
enum Status {
    Active,
    Pending,
    Archived,
}

Another friction point is the default iteration count. proptest runs 256 cases by default. This is enough to catch most logic bugs but not enough to stress-test cryptographic or performance-critical code. Override it by setting the PROPTEST_CASES environment variable. In a CI pipeline, you might set it to 1000 or 10000 to increase coverage. Locally, keep it low to save compile times.

Convention note: use PROPTEST_SEED to reproduce a failing run. When a property test fails in CI, the output prints the seed that generated the failing case. Pass that seed back to your local runner to get the exact same sequence of random values. Debugging becomes deterministic.

Do not fight the trait bounds. If the compiler complains about Arbitrary, derive it or write a strategy. The rest of the harness will fall into place.

When to reach for proptest

Use proptest when you need to verify mathematical properties, invariants, or edge-case boundaries across a wide input space. Use proptest when your function is pure and deterministic, so that repeated runs with the same seed produce identical results. Use proptest when manual test cases feel repetitive or when you suspect your logic breaks at type limits, overflow boundaries, or empty collections. Reach for standard example-based tests when you need to verify specific business rules, API contracts, or integration flows where randomness adds noise instead of value. Reach for fuzzing tools like cargo-afl when you are hardening parsers or compilers against malformed binary input and need continuous, unbounded mutation testing.

Where to go next