Property-Based Testing in Rust: Catch Bugs You Never Thought to Write Tests For

You write a unit test. You pick a few inputs, verify the outputs, call it done. Your CI is green. You ship. Three weeks later, production blows up on an input you never considered — maybe an empty string, maybe an integer overflow at the boundary, maybe a UTF-8 sequence that slipped past your sanitizer.

This is the fundamental problem with example-based testing: you can only test what you think to test. And your imagination, however impressive, is finite.

Property-based testing flips the model. Instead of writing "given input X, expect output Y," you describe properties — invariants that must hold for any valid input. The framework then generates hundreds or thousands of random inputs, tries to break your property, and when it finds a failure, automatically shrinks it to the smallest possible reproducer.

This article covers both major PBT libraries in the Rust ecosystem: quickcheck (the battle-tested classic) and proptest (the modern powerhouse). You’ll learn when to use each, how to write real properties, and where people get burned.

Official repos:


The Mental Shift: From Examples to Properties

Before touching any code, you need to internalize what a property actually is. It’s an assertion about the behavior of your function that should hold for all inputs in a given domain.

Some classic property patterns:

Round-trip properties: encode then decode should give you back what you started with.

Idempotency: applying a function twice should give the same result as applying it once (sorting a list, for example).

Monotonicity: if input A > input B, then f(A) >= f(B) for a monotone function.

Oracle properties: compare your optimized implementation against a slow but obviously correct reference.

Invariant preservation: a function should never break a structural invariant, like "the output list always has the same length as the input" or "the result is always sorted."

If you can’t describe a property for your code, that’s a smell — either the function is doing too many things, or you don’t actually understand what it’s supposed to guarantee.


quickcheck: The Classic

quickcheck is ported from Haskell, written by Andrew Gallant (BurntSushi). It’s lightweight, battle-tested, and integrates directly into Rust’s standard #[test] machinery.

Add it to your Cargo.toml:

[dev-dependencies]
quickcheck = "1"
quickcheck_macros = "1"

Your First Property

Let’s start with something concrete. You have a function that reverses a Vec<i32>. The most obvious property: reversing twice gives you the original.

// src/lib.rs
pub fn my_reverse<T: Clone>(v: &[T]) -> Vec<T> {
    v.iter().rev().cloned().collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use quickcheck_macros::quickcheck;

    #[quickcheck]
    fn reverse_twice_is_identity(xs: Vec<i32>) -> bool {
        my_reverse(&my_reverse(&xs)) == xs
    }

    #[quickcheck]
    fn reverse_preserves_length(xs: Vec<i32>) -> bool {
        my_reverse(&xs).len() == xs.len()
    }

    #[quickcheck]
    fn reverse_preserves_elements(xs: Vec<i32>) -> bool {
        let mut original = xs.clone();
        let mut reversed = my_reverse(&xs);
        original.sort();
        reversed.sort();
        original == reversed
    }
}

The #[quickcheck] macro takes your function, inspects its argument types, uses the Arbitrary trait to generate random values of those types, and runs the test 100 times by default. If it finds a failure, it shrinks the input and reports the minimal counterexample.

The Arbitrary Trait

quickcheck uses the Arbitrary trait to know how to generate random instances of a type. It’s already implemented for all the standard types: integers, floats, strings, Vec, Option, tuples, etc.

For your own types, you implement it yourself:

use quickcheck::{Arbitrary, Gen};

#[derive(Debug, Clone)]
pub struct Email {
    pub local: String,
    pub domain: String,
}

impl Arbitrary for Email {
    fn arbitrary(g: &mut Gen) -> Self {
        // Gen::choose picks from a slice; we build simple valid-ish emails
        let local_len = u8::arbitrary(g) % 10 + 1;
        let domain_len = u8::arbitrary(g) % 8 + 3;

        let local: String = (0..local_len)
            .map(|_| {
                let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
                *g.choose(chars).unwrap() as char
            })
            .collect();

        let domain: String = (0..domain_len)
            .map(|_| {
                let chars = b"abcdefghijklmnopqrstuvwxyz";
                *g.choose(chars).unwrap() as char
            })
            .collect();

        Email { local, domain }
    }

    fn shrink(&self) -> Box<dyn Iterator<Item = Self>> {
        // Shrinking: try shorter local parts
        let local = self.local.clone();
        let domain = self.domain.clone();
        Box::new(
            self.local
                .shrink()
                .map(move |shorter_local| Email {
                    local: shorter_local,
                    domain: domain.clone(),
                }),
        )
    }
}

Gotcha: Implement shrink() properly. If you skip it (just return empty_shrinker()), quickcheck will still find failures but report them at full size — often a 1000-element vector when the actual bug triggers on length 1. Debugging that is miserable.

Controlling Test Count

use quickcheck::QuickCheck;

#[test]
fn intensive_reverse_check() {
    QuickCheck::new()
        .tests(10_000)
        .quickcheck(reverse_twice_is_identity as fn(Vec<i32>) -> bool);
}

For CI you might keep 100. For a pre-release run or a particularly tricky piece of logic, crank it to 10k or more.


proptest: The Modern Approach

proptest takes a different philosophy. Instead of relying on Arbitrary trait implementations with automatic generation, you explicitly compose strategies — descriptors that tell the framework exactly what space of values to explore.

This gives you far more control and makes it trivially easy to generate values from complex, constrained domains.

[dev-dependencies]
proptest = "1"

Basic Usage

use proptest::prelude::*;

fn add(a: i32, b: i32) -> i32 {
    a + b
}

proptest! {
    #[test]
    fn addition_is_commutative(a: i32, b: i32) {
        prop_assert_eq!(add(a, b), add(b, a));
    }

    #[test]
    fn addition_with_zero_is_identity(a: i32) {
        prop_assert_eq!(add(a, 0), a);
    }
}

The proptest! macro works like #[test] but runs the body 256 times by default with randomly generated inputs matching the inferred types.

Strategies: The Real Power

The real reason to reach for proptest over quickcheck is the strategy system. You can precisely define what inputs you want:

use proptest::prelude::*;
use proptest::collection::vec;

proptest! {
    // Only test with non-empty vectors of values 0..100
    #[test]
    fn median_is_within_range(
        xs in vec(0u32..=100u32, 1..=50)
    ) {
        let min = *xs.iter().min().unwrap();
        let max = *xs.iter().max().unwrap();
        let median = compute_median(&xs);
        prop_assert!(median >= min && median <= max);
    }

    // Test with strings that look like realistic identifiers
    #[test]
    fn identifier_roundtrip(
        id in "[a-z][a-z0-9_]{0,31}"
    ) {
        let encoded = encode_identifier(&id);
        let decoded = decode_identifier(&encoded).unwrap();
        prop_assert_eq!(id, decoded);
    }

    // Nested structure generation
    #[test]
    fn json_object_survives_serialize_deserialize(
        keys in vec("[a-z]{1,10}", 1..=10),
        values in vec(any::<i64>(), 1..=10),
    ) {
        prop_assume!(keys.len() == values.len());
        // build and test your JSON object...
    }
}

The regex-based string generation ("[a-z][a-z0-9_]{0,31}") alone is worth the price of admission. Try getting that level of control out of Arbitrary.

Composing Strategies for Custom Types

use proptest::prelude::*;

#[derive(Debug, Clone)]
struct Config {
    timeout_ms: u64,
    retries: u8,
    endpoint: String,
}

fn config_strategy() -> impl Strategy<Value = Config> {
    (
        100u64..=30_000u64,         // realistic timeout range
        0u8..=5u8,                   // 0-5 retries
        "[a-z]{3,10}\\.[a-z]{2,6}", // domain-like endpoint
    )
        .prop_map(|(timeout_ms, retries, endpoint)| Config {
            timeout_ms,
            retries,
            endpoint,
        })
}

proptest! {
    #[test]
    fn config_serialization_roundtrip(cfg in config_strategy()) {
        let serialized = serde_json::to_string(&cfg).unwrap();
        let deserialized: Config = serde_json::from_str(&serialized).unwrap();
        prop_assert_eq!(cfg.timeout_ms, deserialized.timeout_ms);
        prop_assert_eq!(cfg.retries, deserialized.retries);
        prop_assert_eq!(cfg.endpoint, deserialized.endpoint);
    }
}

prop_assume! — Filtering Invalid Inputs

Sometimes your strategy can generate inputs that don’t meet a precondition. Use prop_assume! to discard them:

proptest! {
    #[test]
    fn division_result_is_less_than_dividend(a: u32, b: u32) {
        prop_assume!(b != 0);
        prop_assume!(a > 0);
        prop_assert!(a / b <= a);
    }
}

Gotcha: Don’t overuse prop_assume!. If you’re filtering more than ~10% of generated inputs, you’re wasting cycles and the framework will warn you. The solution is to write a more precise strategy that only generates valid inputs in the first place. Filtering is a lazy escape hatch, not a design pattern.

Failure Persistence

One of proptest’s killer features: when a test fails, it saves the failing input to a proptest-regressions/ directory. On subsequent runs, proptest replays those inputs first before generating new ones. Your regression suite grows automatically.

Commit this directory to version control. It’s a living record of every edge case you’ve encountered.

.
├── proptest-regressions/
│   └── mymodule__tests__my_property.txt  ← auto-generated, commit this
├── src/
│   └── lib.rs
└── Cargo.toml

A Real-World Example: Testing a Parser

Let’s apply this to something you’d actually write at work — a simple key-value config parser.

// src/parser.rs

#[derive(Debug, PartialEq, Clone)]
pub struct KVPair {
    pub key: String,
    pub value: String,
}

pub fn parse_line(line: &str) -> Option<KVPair> {
    let line = line.trim();
    if line.starts_with('#') || line.is_empty() {
        return None;
    }
    let (key, value) = line.split_once('=')?;
    Some(KVPair {
        key: key.trim().to_string(),
        value: value.trim().to_string(),
    })
}

pub fn serialize_pair(pair: &KVPair) -> String {
    format!("{} = {}", pair.key, pair.value)
}

Now test the round-trip property — parse what you serialize, get back what you started with:

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    // Keys: alphanumeric + underscore, no leading digits (like real config keys)
    fn key_strategy() -> impl Strategy<Value = String> {
        "[a-zA-Z_][a-zA-Z0-9_]{0,31}".prop_map(String::from)
    }

    // Values: printable ASCII, no '=' or newlines (would break parsing)
    fn value_strategy() -> impl Strategy<Value = String> {
        "[a-zA-Z0-9 .,!@#$%^&*()\\[\\]]{0,64}".prop_map(String::from)
    }

    proptest! {
        #[test]
        fn serialize_parse_roundtrip(
            key in key_strategy(),
            value in value_strategy(),
        ) {
            let original = KVPair {
                key: key.clone(),
                value: value.clone(),
            };
            let serialized = serialize_pair(&original);
            let parsed = parse_line(&serialized);

            prop_assert!(parsed.is_some(), "parse_line returned None for: {:?}", serialized);
            let parsed = parsed.unwrap();
            prop_assert_eq!(&parsed.key, &key);
            prop_assert_eq!(&parsed.value, &value);
        }

        #[test]
        fn parse_never_panics(line: String) {
            // Just... don't panic. Ever.
            let _ = parse_line(&line);
        }
    }
}

That last test — parse_never_panics — is brutally effective. Feed your parser completely arbitrary strings and assert it never panics. No logic, no verification, just stability. I’ve caught unwrap() calls on malformed input this way more times than I’d like to admit.


Gotchas and Production Notes

Floating point is a trap. f64::arbitrary() and any::<f64>() will gleefully generate NaN, +Inf, -Inf, -0.0. Your properties must account for this or you’ll be drowning in false positives. Either exclude them with prop_assume!(!x.is_nan() && x.is_finite()) or use a strategy like proptest::num::f64::NORMAL.

Default test counts are low. proptest defaults to 256 cases, quickcheck to 100. That’s fine for development iteration, but for critical code — parsers, serializers, cryptographic primitives — bump these to 10k+ in at least one CI job. Use ProptestConfig::with_cases(10_000) or set PROPTEST_CASES=10000 as an environment variable.

proptest! {
    #![proptest_config(ProptestConfig::with_cases(10_000))]

    #[test]
    fn critical_invariant(x: u64) {
        // ...
    }
}

Seeding for reproducibility. proptest uses a deterministic PRNG seeded from the system. Set PROPTEST_SEED to a fixed value to make CI deterministic. Just remember to occasionally run with a fresh seed.

Don’t test implementation details. The biggest mistake newcomers make: writing properties that encode the specific algorithm rather than the specification. If you’re testing a sort function, the property is "output is sorted and contains the same elements as input" — not "the pivot chosen in quicksort is the median of three." The former catches bugs, the latter just documents your code.

quickcheck vs proptest: when to choose which. Use quickcheck when you want zero-ceremony tests on types that already have Arbitrary implementations — it’s less boilerplate for simple cases. Reach for proptest when you need constrained domains, regex-based string generation, structured composite strategies, or the regression persistence feature. On a new project today, I’d default to proptest.

Property tests don’t replace unit tests. They complement them. Keep your example-based tests for documented edge cases, error messages, and specific regression cases. Use property tests to stress the general invariants. Both have a role.


Integrating with the Standard Test Suite

Both libraries slot directly into cargo test. No special runner needed.

# Run all tests including property tests
cargo test

# Run only property-based tests
cargo test -- --test-filter "proptest"

# Run with more cases via env var (proptest)
PROPTEST_CASES=5000 cargo test

# Run with more tests via env var (quickcheck)  
QUICKCHECK_TESTS=5000 cargo test

For CI, I recommend a two-tier approach: fast property tests (default case counts) in the regular test suite, and a separate nightly job with PROPTEST_CASES=50000 that catches rarer failures without slowing down your PR feedback loop.


Where to Go Next

If you’re getting serious about correctness, look at cargo-fuzz — it uses libFuzzer to do coverage-guided fuzzing, which is PBT taken to its logical extreme. proptest and quickcheck find bugs through random generation; cargo-fuzz actively guides the fuzzer toward unexplored code paths.

For state machine testing (does your distributed system maintain invariants across sequences of operations?), proptest has a StateMachine testing module. That’s where things get genuinely powerful for testing things like databases, caches, and protocol implementations.

The investment in property-based testing pays back in proportion to how hard your domain is to reason about. For business logic with lots of branching and edge cases, it’s table stakes. For a CRUD wrapper, it might be overkill. Use judgment — but once you’ve caught your first real production bug with a two-line property, you’ll start seeing properties everywhere.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646