Rust Property Testing: proptest Framework

Traditional unit tests verify specific examples: given input X, expect output Y. This approach has a fundamental limitation—you're only testing the cases you thought of. Property-based testing flips...

Key Insights

  • Property-based testing with proptest discovers edge cases that example-based tests miss by generating hundreds of random inputs and verifying invariants hold across all of them.
  • Proptest’s shrinking algorithm automatically reduces failing inputs to minimal reproducible examples, transforming a 500-character failing string into the smallest input that still triggers the bug.
  • Effective property testing requires thinking about your code differently—instead of “does this specific input produce this specific output,” you ask “what properties must always be true regardless of input?”

Introduction to Property-Based Testing

Traditional unit tests verify specific examples: given input X, expect output Y. This approach has a fundamental limitation—you’re only testing the cases you thought of. Property-based testing flips this model. Instead of specifying examples, you define properties that must hold for all valid inputs, then let the framework generate hundreds or thousands of random test cases.

Consider testing a sorting function. An example-based test might check that [3, 1, 2] becomes [1, 2, 3]. A property-based test asks: “For any input list, is the output sorted? Does it contain the same elements? Is the length preserved?” These properties catch bugs that specific examples miss—off-by-one errors on lists of length 17, integer overflow on extreme values, or subtle issues with duplicate elements.

The proptest framework brings property-based testing to Rust with an ergonomic API, powerful data generation strategies, and automatic shrinking of failing cases.

Getting Started with proptest

Add proptest to your Cargo.toml as a dev dependency:

[dev-dependencies]
proptest = "1.4"

The proptest! macro is your entry point. It generates test cases, runs your assertions, and handles shrinking when failures occur:

use proptest::prelude::*;

fn reverse<T: Clone>(input: &[T]) -> Vec<T> {
    input.iter().rev().cloned().collect()
}

proptest! {
    #[test]
    fn reverse_twice_is_identity(input: Vec<i32>) {
        let reversed_twice = reverse(&reverse(&input));
        prop_assert_eq!(reversed_twice, input);
    }

    #[test]
    fn reverse_preserves_length(input: Vec<i32>) {
        prop_assert_eq!(reverse(&input).len(), input.len());
    }
}

The macro expands each test into a function that runs 256 random cases by default. The input: Vec<i32> parameter tells proptest to generate random vectors of random integers. When an assertion fails, proptest captures the failing input and begins shrinking.

Use prop_assert! and prop_assert_eq! instead of standard assertions—they integrate with proptest’s shrinking mechanism and provide better error messages.

Strategies: Generating Test Data

Strategies define how proptest generates random values. Built-in strategies cover primitives, strings, collections, and more:

use proptest::prelude::*;

proptest! {
    #[test]
    fn string_operations(s in "\\PC*") {  // any unicode string
        // test string handling
    }

    #[test]
    fn bounded_integers(n in 0i32..100) {
        prop_assert!(n >= 0 && n < 100);
    }

    #[test]
    fn sized_vectors(v in prop::collection::vec(any::<u8>(), 1..10)) {
        prop_assert!(v.len() >= 1 && v.len() < 10);
    }
}

For domain-specific types, compose strategies with prop_map and prop_flat_map:

use proptest::prelude::*;

#[derive(Debug, Clone, PartialEq)]
struct User {
    id: u64,
    username: String,
    email: String,
    age: u8,
}

fn valid_username() -> impl Strategy<Value = String> {
    "[a-z][a-z0-9_]{2,15}".prop_map(|s| s.to_lowercase())
}

fn valid_email() -> impl Strategy<Value = String> {
    (valid_username(), "[a-z]{2,10}\\.(com|org|net)")
        .prop_map(|(user, domain)| format!("{}@{}", user, domain))
}

fn user_strategy() -> impl Strategy<Value = User> {
    (
        any::<u64>(),
        valid_username(),
        valid_email(),
        18u8..120,
    )
        .prop_map(|(id, username, email, age)| User {
            id,
            username,
            email,
            age,
        })
}

proptest! {
    #[test]
    fn user_email_contains_username(user in user_strategy()) {
        prop_assert!(user.email.contains(&user.username));
    }
}

Use prop_flat_map when generating one value depends on another:

fn vec_with_valid_index() -> impl Strategy<Value = (Vec<i32>, usize)> {
    prop::collection::vec(any::<i32>(), 1..100)
        .prop_flat_map(|vec| {
            let len = vec.len();
            (Just(vec), 0..len)
        })
}

Shrinking: Finding Minimal Failing Cases

When a test fails, the randomly generated input is often large and complex. Proptest’s shrinking algorithm iteratively reduces the input while preserving the failure, producing a minimal reproducible example.

Consider a buggy sorting function:

fn buggy_sort(mut items: Vec<i32>) -> Vec<i32> {
    // Bug: fails when vector has more than 5 elements
    if items.len() <= 5 {
        items.sort();
    }
    items
}

fn is_sorted(items: &[i32]) -> bool {
    items.windows(2).all(|w| w[0] <= w[1])
}

proptest! {
    #[test]
    fn sort_produces_sorted_output(input: Vec<i32>) {
        let sorted = buggy_sort(input);
        prop_assert!(is_sorted(&sorted));
    }
}

Running this test produces output like:

thread 'sort_produces_sorted_output' panicked at 'Test failed: 
    is_sorted(&sorted) was false
    
minimal failing input: input = [1, 0, 0, 0, 0, 0]
    successes: 42
    local rejects: 0

The original failing input might have been a 50-element vector with random values. Proptest shrunk it to exactly 6 elements (the minimum to trigger the bug) with the simplest values that demonstrate the failure. This transforms debugging from “why does this 50-element mess fail?” to “why does [1, 0, 0, 0, 0, 0] fail?"—immediately pointing to the length condition.

Testing Stateful Systems with proptest-state-machine

For systems with state, testing individual operations isn’t enough. You need to verify that arbitrary sequences of operations maintain invariants. The proptest-state-machine crate models this:

use proptest::prelude::*;
use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest};
use std::collections::HashMap;

#[derive(Debug, Clone)]
struct KVStore {
    data: HashMap<String, i32>,
}

impl KVStore {
    fn new() -> Self {
        Self { data: HashMap::new() }
    }
    
    fn put(&mut self, key: String, value: i32) {
        self.data.insert(key, value);
    }
    
    fn get(&self, key: &str) -> Option<i32> {
        self.data.get(key).copied()
    }
    
    fn delete(&mut self, key: &str) -> Option<i32> {
        self.data.remove(key)
    }
}

#[derive(Debug, Clone)]
enum Command {
    Put(String, i32),
    Get(String),
    Delete(String),
}

struct KVStoreTest;

impl ReferenceStateMachine for KVStoreTest {
    type State = HashMap<String, i32>;
    type Transition = Command;

    fn init_state() -> Self::State {
        HashMap::new()
    }

    fn transitions(state: &Self::State) -> BoxedStrategy<Self::Transition> {
        let keys: Vec<String> = state.keys().cloned().collect();
        
        if keys.is_empty() {
            prop_oneof![
                ("[a-z]{1,5}", any::<i32>()).prop_map(|(k, v)| Command::Put(k, v)),
            ].boxed()
        } else {
            prop_oneof![
                ("[a-z]{1,5}", any::<i32>()).prop_map(|(k, v)| Command::Put(k, v)),
                proptest::sample::select(keys.clone()).prop_map(Command::Get),
                proptest::sample::select(keys).prop_map(Command::Delete),
            ].boxed()
        }
    }

    fn apply(state: Self::State, transition: &Self::Transition) -> Self::State {
        let mut state = state;
        match transition {
            Command::Put(k, v) => { state.insert(k.clone(), *v); }
            Command::Get(_) => {}
            Command::Delete(k) => { state.remove(k); }
        }
        state
    }
}

impl StateMachineTest for KVStoreTest {
    type SystemUnderTest = KVStore;

    fn init_test(_: &<Self as ReferenceStateMachine>::State) -> Self::SystemUnderTest {
        KVStore::new()
    }

    fn apply(
        state: Self::SystemUnderTest,
        _ref_state: &<Self as ReferenceStateMachine>::State,
        transition: <Self as ReferenceStateMachine>::Transition,
    ) -> Self::SystemUnderTest {
        let mut state = state;
        match transition {
            Command::Put(k, v) => state.put(k, v),
            Command::Get(k) => { state.get(&k); }
            Command::Delete(k) => { state.delete(&k); }
        }
        state
    }
}

prop_state_machine! {
    #[test]
    fn kv_store_model_test(sequential 1..50 => KVStoreTest);
}

This generates random sequences of operations and verifies the actual implementation matches the reference model.

Integrating proptest into CI/CD

Configure proptest behavior through proptest.toml in your project root:

[default]
cases = 500          # tests per property in CI
max_shrink_iters = 10000
timeout = 60000      # milliseconds

[dev]
cases = 100          # faster iteration locally

Environment variables override configuration:

# CI pipeline
PROPTEST_CASES=1000 cargo test

# Reproduce a specific failure
PROPTEST_SEED="0x1234567890abcdef" cargo test failing_test

Proptest writes failing seeds to proptest-regressions/ files. Commit these to your repository—they ensure failing cases remain tested even after the random seed changes:

// proptest-regressions/my_module/my_test.txt
# Seeds for failure persistence
cc 1234567890abcdef...

For CI, balance thoroughness against build time:

# .github/workflows/test.yml
- name: Run property tests
  run: cargo test --release
  env:
    PROPTEST_CASES: 1000
  timeout-minutes: 15

Best Practices and Common Pitfalls

Write properties, not examples. The property “sorting preserves length” is useful. The property “sorting [3,1,2] produces [1,2,3]” is just an example test with extra steps.

Combine with unit tests. Property tests verify general invariants; unit tests document specific behaviors and edge cases. Use both.

Constrain inputs appropriately. Testing a function that requires non-empty input? Use vec(any::<T>(), 1..100), not vec(any::<T>(), 0..100) with a filter. Filtering wastes generated cases.

Avoid side effects in strategies. Strategies should be pure—no file I/O, no network calls, no shared mutable state.

Watch for slow strategies. Complex prop_flat_map chains can slow test generation. Profile if tests take unexpectedly long.

Don’t ignore shrinking failures. If shrinking times out, your test might have non-deterministic behavior or external dependencies interfering with reproducibility.

Property testing excels for pure functions with clear invariants, serialization/deserialization roundtrips, data structure operations, and parser implementations. It’s less valuable for code that’s mostly integration with external systems or where properties are hard to articulate.

Start with one or two property tests for your most critical pure functions. As you develop intuition for writing properties, expand coverage. The bugs proptest finds—the ones you never thought to test—justify the investment.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.