How to Write Property-Based Tests in Rust With Proptest: A Complete Guide

Discover how property-based testing in Rust with `proptest` catches edge cases unit tests miss. Learn 8 techniques to write stronger, reliable tests.

How to Write Property-Based Tests in Rust With Proptest: A Complete Guide

I used to test my Rust code by writing one example at a time. I would invent a number, feed it to a function, and check the result. After a while, I noticed a pattern: the examples I chose were the ones I expected to work. They were not the strange inputs that would break my code. A fixed example is like a lock test that only tries one key. It tells you that key works. It does not tell you whether the other keys on your chain are bent.

Property-based testing turns that process around. You do not pick the inputs. You write a rule that the output must follow, and the test runner creates hundreds or thousands of random inputs to see if the rule ever breaks. In Rust, the proptest crate does this well. It generates values, feeds them to your test, and if a property fails, it shrinks the input down to the simplest case that still fails. That shrinking step is a gift. Instead of seeing a wall of random bytes, you see the one tiny detail responsible for the failure. For every example below, think of the property as a promise. The test runner is a customer who keeps changing the order to see if you break that promise.

The first technique is to start with simple strategies for primitives. The building blocks in proptest are small and reusable. any::<u32>() generates any unsigned 32-bit integer. any::<bool>() generates true and false. Character ranges and byte vectors are just as easy. I like to think of these primitive strategies as LEGO bricks. A single brick may not test anything interesting, but an entire function is almost always built from these bricks under the surface.

use proptest::prelude::*;

fn double(x: u32) -> Option<u32> {
    x.checked_mul(2)
}

proptest! {
    #[test]
    fn checked_double_is_always_some(x in 0u32..=u32::MAX / 2) {
        prop_assert!(double(x).is_some());
    }
}

That property is not complicated. It says that as long as the input is small enough, doubling it should not overflow. A regular unit test would check one value, maybe double(200). The property test tests every value in that range. Well, it does not test every value literally, but it tests enough random values from the range to make the rule uncomfortable. I remember the first time I ran a property test with a vector of bytes. I found an off-by-one error in my parser that I had completely missed because I only tested five handwritten byte arrays.

The second technique is composing custom strategies from your own types. Raw numbers are important, but your code probably does not work on raw numbers. It works on structs, enums, and domain objects. The prop_map method turns the output of one strategy into another shape. For example, you can combine three primitive strategies into a User struct. The mapper function is plain Rust, so it is easy to read.

use proptest::prelude::*;

#[derive(Debug)]
struct Customer {
    id: u32,
    is_admin: bool,
    name: String,
}

fn customer_strategy() -> impl Strategy<Value = Customer> {
    (
        any::<u32>(),
        any::<bool>(),
        "[a-zA-Z]{1,20}",
    )
        .prop_map(|(id, is_admin, name)| Customer {
            id,
            is_admin,
            name,
        })
}

proptest! {
    #[test]
    fn customer_names_are_not_empty(customer in customer_strategy()) {
        prop_assert!(!customer.name.is_empty());
    }
}

That is a straightforward map. Sometimes one value depends on another. In that case prop_flat_map is your friend. Say you want to generate a vector with a length first, then fill the vector with that many characters. You need the second strategy to know the length chosen by the first. That is called a dependent strategy. Here is a small generator for strings of one to eight lowercase letters.

fn small_word() -> impl Strategy<Value = String> {
    (1usize..=8)
        .prop_flat_map(|length| {
            proptest::collection::vec(any::<char>(), length)
        })
        .prop_map(|letters| letters.into_iter().collect())
}

The prop_flat_map part allows the vector length to depend on the length value generated before it. I use this pattern a lot when creating realistic test data. A network packet may need a header length field, and the body of the packet should match that field. The types are no longer separate random numbers. They belong together.

For enums with several shapes, prop_oneof! chooses among strategies. I use it to generate the different variants of an enum with some probability.

#[derive(Debug)]
enum Payment {
    Cash { cents: u32 },
    Card { number: String },
}

fn payment_strategy() -> impl Strategy<Value = Payment> {
    prop_oneof![
        any::<u32>().prop_map(|cents| Payment::Cash { cents }),
        "[0-9]{12,19}".prop_map(|number| Payment::Card { number }),
    ]
}

If you want one variant to appear more often than another, add a weight before the strategy. The important part is that the test now speaks in the language of the domain you are actually testing.

The third technique is filtering invalid inputs with prop_assume!. Many properties are only true under a precondition. A divide-by-zero function may be defined only for a nonzero denominator. A search function may only make sense on a sorted list. You can either build a strategy that only produces valid inputs, which is often hard, or you can tell the test runner to skip cases that do not meet the precondition. prop_assume! does that quietly.

proptest! {
    #[test]
    fn first_character_stays_after_removing_spaces(text in ".*") {
        let trimmed = text.trim_start();
        prop_assume!(!trimmed.is_empty());
        let first = trimmed.chars().next().unwrap();
        prop_assert!(trimmed.replace(' ', "").starts_with(first));
    }
}

The assumption here says that if the string becomes empty after trimming, the rest of the property is not interesting. The test runner does not treat that case as a failure. It simply throws the case away and tries another. I used prop_assume! in a project where I was testing a function that required sorted input. Sorting the generated vector inside the test made the property too broad. I needed to reject unsorted vectors before reaching the property. Later I realized it was better to write a generator that always sorted the output. But for those situations where the condition is naturally rare or expensive to express as a generator, prop_assume! is a clean escape door. Do not make the assumption too narrow, though. If most cases are discarded, you are not testing much.

The fourth technique is round-trip properties for serialization and parsing. This is one of the easiest property tests to write and one of the most valuable. You format a value into bytes or text, then parse it back. The property is simple: the second step returns the original value. That rule catches lost precision, truncated output, forgotten fields, and timezone mistakes.

fn render_time(total_seconds: u32) -> String {
    let hours = total_seconds / 3600;
    let minutes = (total_seconds % 3600) / 60;
    let seconds = total_seconds % 60;
    format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
}

fn parse_time(text: &str) -> Option<u32> {
    let mut pieces = text.split(':');
    let hours: u32 = pieces.next()?.parse().ok()?;
    let minutes: u32 = pieces.next()?.parse().ok()?;
    let seconds: u32 = pieces.next()?.parse().ok()?;
    if pieces.next().is_some() || minutes >= 60 || seconds >= 60 {
        return None;
    }
    Some(hours * 3600 + minutes * 60 + seconds)
}

proptest! {
    #[test]
    fn time_text_round_trips(seconds in 0u32..86_400) {
        let text = render_time(seconds);
        prop_assert_eq!(parse_time(&text), Some(seconds));
    }
}

This test found a real bug in my own formatting code not long ago. I was building a clock display, and when the seconds value reached exactly 86400, my formatter produced 24:00:00. My parser accepted that as a valid time because the hours field had no upper bound. A unit test probably would have checked a few neat values like 60, 3600, and 86399. It might have missed 86399 or 3599. The property test tried all those values and more. If a round-trip property fails, the shrinking step often produces a value such as 3599 instead of a random seven-digit monster. That makes debugging not just possible but pleasant.

The fifth technique is comparing against a simple reference model. When you write an optimized version of an algorithm, you need a trusted answer to compare against. The naive version is often slower but easier to read. The property test checks that both versions come to the same conclusion. I use this for sorting, deduplication, state changes, and numeric functions.

fn unique_count_reference(values: &[i32]) -> usize {
    let mut seen = Vec::new();
    for value in values {
        if !seen.contains(value) {
            seen.push(*value);
        }
    }
    seen.len()
}

fn unique_count_fast(values: &[i32]) -> usize {
    let mut sorted = values.to_vec();
    sorted.sort_unstable();
    sorted.dedup();
    sorted.len()
}

proptest! {
    #[test]
    fn fast_unique_count_matches_reference(values in proptest::collection::vec(any::<i32>(), 0..100)) {
        prop_assert_eq!(unique_count_fast(&values), unique_count_reference(&values));
    }
}

The reference model here is not clever. It loops over the array and checks each item against everything it has already seen. That is slow for large arrays, but it is so simple that I trust it. The fast version sorts the array, removes adjacent duplicates, and counts what remains. If my sorting implementation, my dedup call, or my length calculation has a bug, the property test will find it. I learned to write reference models before I write complex code. The reference model is like a map of the city. The optimized algorithm is the taxi driver who claims to know a shortcut. The map may be slower, but it still knows the correct destination.

The sixth technique is stateful system testing. Many bugs appear only when you call a sequence of methods in the wrong order. A single call to a bank account may work perfectly. The bug appears when you deposit, withdraw, deposit again, and then try to withdraw more than the balance. To test that, generate a sequence of operations and check that the real system always stays in sync with a lightweight model.

#[derive(Debug, Default)]
struct Account {
    balance: u64,
}

impl Account {
    fn deposit(&mut self, amount: u64) {
        self.balance = self.balance.checked_add(amount).expect("no overflow in test");
    }

    fn withdraw(&mut self, amount: u64) -> Result<(), ()> {
        if amount > self.balance {
            Err(())
        } else {
            self.balance -= amount;
            Ok(())
        }
    }
}

#[derive(Debug, Clone, Copy)]
enum Action {
    Deposit(u64),
    Withdraw(u64),
}

fn action_strategy() -> impl Strategy<Value = Action> {
    prop_oneof![
        (1u64..10_000).prop_map(Action::Deposit),
        (1u64..10_000).prop_map(Action::Withdraw),
    ]
}

proptest! {
    #[test]
    fn account_actions_match_model(actions in proptest::collection::vec(action_strategy(), 0..100)) {
        let mut model_balance = 0u64;
        let mut account = Account::default();

        for action in actions {
            match action {
                Action::Deposit(amount) => {
                    model_balance += amount;
                    account.deposit(amount);
                }
                Action::Withdraw(amount) => {
                    if amount <= model_balance {
                        model_balance -= amount;
                        account.withdraw(amount).expect("enough balance should succeed");
                    } else {
                        let balance_before = account.balance;
                        prop_assert!(account.withdraw(amount).is_err());
                        prop_assert_eq!(account.balance, balance_before);
                    }
                }
            }
            prop_assert_eq!(account.balance, model_balance);
        }
    }
}

The model here is just an integer called model_balance. Every real deposit adds to it. Every real withdrawal subtracts from it only if there is enough money. At the end of each operation, the model and the real account must agree. If my account accidentally allowed a withdrawal below zero, the model would not. The property test would report the exact sequence of actions that exposed the issue. The proptest-state-machine crate turns this manual loop into a reusable framework. You define each operation once, and the crate generates whole traces, applies them, and shrinks the sequence. The idea is the same. Keep a small, honest model beside the real system and let random traces find the differences.

The seventh technique is persisting and replaying failing cases. Random testing has a reputation for finding a bug once and then losing it. proptest handles that by writing failing inputs to a regression file. When the file exists, later test runs start with the saved failures before generating new random cases. I used to worry about this. I thought random tests would make my test suite nondeterministic. Then I saw the file named proptest-regressions appear next to my test source. It contained the tiny string that had crashed my parser. The next time I ran cargo test, that string was checked first. If the bug ever returned, the test would fail immediately, even before the random runner generated a single new value.

You can make this even stronger by adding a hand-written regression test for the most important shrunk case. After proptest shrinks an input, you should know exactly which rule broke. That small case is worth keeping visible. A regression test does not have to be a big proptest block. It can be a normal #[test] function. I tend to add those next to the main property test.

#[test]
fn regression_parse_time_rejects_two_digit_minutes() {
    assert_eq!(parse_time("12:99:00"), None);
}

This exact case came from a failed property test. The runner found a text string such as "99:99:00" and shrank it until the interesting problem was the minutes field. The regression file would have retained the shrunk input anyway, but I like the visible test too. It documents the bug for the next person reading the code. It also gives me confidence that a fix for the property test did not slip through by accident. The persisted failures are your insurance policy. The manual regression test is the receipt.

The eighth technique is measuring coverage to find untested assumptions. Property tests can generate thousands of random inputs, and your code can still miss a branch because your generator never produces a value that reaches that branch. I learned this after writing a parser with several error branches. My property test ran for a full minute and never crashed. I felt proud. Then I installed cargo-llvm-cov and ran coverage. The report showed that two branches in the parser had never run. The generator was only producing short ASCII text, so the error branch for an unexpected null byte was always skipped. Random inputs are not magic. They are only as good as the strategy that creates them.

Run coverage on your property tests the same way you would run coverage on a regular test suite.

cargo install cargo-llvm-cov
cargo llvm-cov --workspace --open

The colored report will show green lines that executed and red lines that did not. When you see red in a property test, the first question is not “is my code wrong?” The first question is “does my generator know this part of the input space even exists?” If the coverage report shows that a function only handles uppercase letters, add a strategy that includes lowercase letters. If a state machine only tests successful withdrawals, add a strategy that produces overdraft actions. I now run coverage before and after expanding every generator. The second run usually shows a new set of branches that were dark before. Expanding a simple range or adding a prop_oneof! variant can reveal assumptions that had been hiding in the code for months.

Property-based testing is not a replacement for unit tests. It is a different animal. A unit test tells a story about one expected behavior. A property test tells the computer to find a counterexample to a rule. In Rust, the type system does a lot of heavy lifting before the test runner even starts. When you combine strong types with a tool such as proptest, the remaining holes tend to be edge cases in parsing, concurrency, state transitions, and numeric overflow. That is exactly where property tests shine.

The first property test I ever wrote was for a function that reversed a byte slice. The property was simple: reversing twice returns the original slice. It passed. I thought the technique was silly. Then I wrote a property test for a custom binary format. It failed within a second, and the shrunk input was one byte longer than the format allowed. I never would have chosen that input by hand. That moment changed the way I test. Now I start every new module by asking what rules should always be true. If I cannot think of a rule, I write a reference model. If I cannot write a reference model, I build an operation sequence and compare a real object to a simple model. The property is always on my side. It does not care about my pride. It only cares about the truth.


// Keep Reading

Similar Articles