proptest (Rust): Property Testing & Shrinking Guide

Max WellsMax WellsFounder of Rustify

TL;DR: Property-based testing generates hundreds of random inputs and verifies that a property holds for all of them; instead of testing specific examples you chose manually. proptest is the most popular property-based testing crate in Rust. Write a proptest! block, define input strategies (ranges, regex, arbitrary structs), and let proptest find failures. When it finds a failure, it "shrinks" the input to the smallest failing case automatically. Great for finding edge cases, overflow bugs, and round-trip invariants.


What Is Property-Based Testing?

Instead of "input X gives output Y", you assert "for ALL inputs in this domain, this property holds"; proptest generates the inputs.

# Cargo.toml
[dev-dependencies]
proptest = "1"
// Traditional unit test; you pick the examples
#[test]
fn test_reverse() {
    assert_eq!(reverse_string("hello"), "olleh");
    assert_eq!(reverse_string(""), "");
    assert_eq!(reverse_string("a"), "a");
    // What about Unicode? Long strings? Null bytes?
}
 
// Property test; proptest picks thousands of examples
use proptest::prelude::*;
 
proptest! {
    #[test]
    fn reverse_twice_is_identity(s in ".*") {
        // For any string, reversing twice gives the original
        let result = reverse_string(&reverse_string(&s));
        prop_assert_eq!(result, s);
    }
}

How Do You Write Basic Property Tests?

use proptest::prelude::*;
 
fn add(a: i32, b: i32) -> i32 { a + b }
fn sort(mut v: Vec<i32>) -> Vec<i32> { v.sort(); v }
 
proptest! {
    // Arithmetic properties
    #[test]
    fn add_is_commutative(a in i32::MIN/2..=i32::MAX/2, b in i32::MIN/2..=i32::MAX/2) {
        prop_assert_eq!(add(a, b), add(b, a));
    }
 
    #[test]
    fn add_is_associative(a in -1000i32..1000, b in -1000i32..1000, c in -1000i32..1000) {
        prop_assert_eq!(add(add(a, b), c), add(a, add(b, c)));
    }
 
    // Collection properties
    #[test]
    fn sorted_length_unchanged(v in prop::collection::vec(any::<i32>(), 0..100)) {
        let sorted = sort(v.clone());
        prop_assert_eq!(sorted.len(), v.len());
    }
 
    #[test]
    fn sorted_is_ordered(v in prop::collection::vec(any::<i32>(), 0..100)) {
        let sorted = sort(v);
        for window in sorted.windows(2) {
            prop_assert!(window[0] <= window[1]);
        }
    }
}

How Do You Generate Custom Types?

Use #[derive(Arbitrary)] for simple types, or compose strategies for complex ones.

use proptest::prelude::*;
use proptest_derive::Arbitrary;
 
#[derive(Debug, Arbitrary)]
struct User {
    #[proptest(regex = "[a-z]{3,20}")]
    username: String,
    #[proptest(strategy = "18u32..=120")]
    age: u32,
    active: bool,
}
 
proptest! {
    #[test]
    fn user_validation_never_panics(user in any::<User>()) {
        // Ensure validation handles any input without panicking
        let _ = validate_user(&user);
    }
}

Manual strategy composition:

fn valid_email() -> impl Strategy<Value = String> {
    (r"[a-z]{3,10}", r"[a-z]{3,10}", r"(com|org|net)")
        .prop_map(|(user, domain, tld)| format!("{user}@{domain}.{tld}"))
}
 
proptest! {
    #[test]
    fn email_parser_accepts_valid_emails(email in valid_email()) {
        assert!(parse_email(&email).is_ok(), "rejected valid email: {email}");
    }
}

How Does Shrinking Work?

When proptest finds a failing input, it automatically reduces it to the smallest case that still fails; making bugs easy to debug.

Failure found with input: "xgqmplwzabcdefgh..."
Shrinking...
Minimal failing input: "abc"

This is one of the key advantages over manual fuzzing; you get a minimal reproduction immediately.


What Are Common Property Test Patterns?

PatternWhat to assert
Round-tripdecode(encode(x)) == x
Inverse operationsdecrypt(encrypt(x, k), k) == x
Idempotencef(f(x)) == f(x)
Monotonicityif a <= b then f(a) <= f(b)
No paniclet _ = risky_function(x)
Length preservationtransform(v).len() == v.len()
Commutativityf(a, b) == f(b, a)

Frequently Asked Questions

Both are property-based testing libraries. proptest has more powerful strategies (regex-based string generation, better shrinking, derives), richer composition, and is more actively maintained. quickcheck is simpler and older. For new projects, prefer proptest.

256 by default. Configure with ProptestConfig:

proptest! {
    #![proptest_config(ProptestConfig::with_cases(1000))]
    #[test]
    fn my_test(x in any::<i32>()) { ... }
}

Yes; it saves the seed and minimal failing case in a .proptest-regressions directory so CI always re-runs known failures.

Not directly in proptest! blocks. Run async code with a local runtime:

proptest! {
    #[test]
    fn async_property(input in any::<String>()) {
        let rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async { my_async_fn(&input).await });
    }
}

Sources


  • Result: Property tests often verify Result invariants
  • Iterator: proptest strategies are iterator-like
  • Enum: #[derive(Arbitrary)] works on enums too
  • Criterion: Property tests and benchmarks pair well when validating both correctness and performance envelopes
  • Rand: Generated test cases are conceptually close to randomized input generation workflows

Keep Reading

Ready to Land a $80-120k Rust Job?