Your Unit Tests Only Cover the Cases You Remembered

You wrote a reverse function. You tested it with [1, 2, 3] and [4, 5, 6, 7]. It passes. You ship it.

A user passes a single-element slice. Your function drops it on the floor. They open an issue. You stare at the test file and wonder how you missed something so obvious.

You missed it because example-based testing only catches bugs you anticipate. Every assert_eq! in your test suite is a guess about where the failure might hide. Property-based testing replaces those guesses with random data and mathematical invariants. It will find the single-element bug. It will also find the empty-vector bug, the integer-overflow edge case, and the Unicode corner case you did not know existed.

Random Inputs and Invariants, Not Examples and Expectations

Property-based testing flips the script. Instead of choosing inputs and asserting exact outputs, you define a property that must hold for all inputs, and the test framework generates hundreds of random inputs to try to break it.

The classic example is reversing a list. The property is not “reverse of [1, 2, 3] is [3, 2, 1].” The property is “reversing a list twice returns the original list.” That invariant holds for every possible list, so the framework throws random lists at it until something breaks or the iteration limit hits.

Rust has two mature libraries for this. quickcheck was the first popular option, ported from Haskell. proptest is the newer approach with a decisive feature: automatic shrinking. When proptest finds a failing input, it does not just print a hundred-element vector and leave you to debug. It simplifies the input until it finds the smallest possible counterexample. That is the difference between reading a failed test output and actually understanding the bug.

How Shrinking Finds the Real Bug

Shrinking is the secret sauce. Say proptest generates a random vector of 47 integers and your property fails. The bug is not the 47th element. The bug is probably something about empty vectors, or sign changes, or duplicate values.

proptest knows this. It tries removing elements. It tries zeroing values. It tries making the vector smaller and smaller. If the test still fails with a smaller input, it keeps shrinking. It stops when every simpler variation passes. The result is the minimal counterexample.

This is not a nice-to-have. It is the reason property-based testing is usable in practice. Without shrinking, you are back to debugging a haystack. With it, you get a needle.

A Real Bug, a Real Test

Here is a reverse function with a subtle bug. See if you spot it before the test does.

fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
    let mut rev = Vec::with_capacity(xs.len());
    for i in 1..xs.len() {
        rev.push(xs[xs.len() - i].clone());
    }
    rev
}

The range starts at 1 instead of 0. For any non-empty list, the first element is silently dropped. A unit test with [1, 2, 3] might pass if the author only checked that the output looked reversed, not that it had the right length. But the property catches it instantly.

Add proptest to your Cargo.toml:

[dev-dependencies]
proptest = "1.6"

Then write the property:

use proptest::prelude::*;

proptest! {
    #[test]
    fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
        let rev = reverse(&xs);
        let rev_rev = reverse(&rev);
        prop_assert_eq!(xs, rev_rev);
    }
}

Run cargo test and the output tells the story:

thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
  left: `[0]`,
 right: `[]`'

proptest shrank a failing random vector down to a single element, [0]. The first call returns []. The second call returns []. The property demands [] == [0], which fails. The bug is obvious once the input is minimal.

You can also test structural properties directly:

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

This fails with the same minimal input but gives an even clearer error: the output length is 0, not 1.

Why You Still Need Regular Unit Tests

Property-based testing is not a replacement for example-based tests. It is a complement.

Unit tests document intent. When a new developer reads that reverse(&[1, 2, 3]) should equal [3, 2, 1], they learn what the function is supposed to do. A property test says “this invariant holds,” but it does not tell you what the output looks like for a normal case.

Properties are also harder to write than examples. State machines, side effects, and I/O do not map cleanly to pure functions. proptest has strategies for complex types, but wiring them up takes more thought than a few assert_eq! calls.

There is also the time cost. A property test might run a thousand iterations. In a large codebase, that adds up. You run property tests in CI, not on every keystroke during development.

Random Tests Can Still Be Reproducible

Randomness makes people nervous. If a test fails only sometimes, is it the code or the test?

proptest handles this by persisting failing seeds. When a property fails, it saves the seed and the shrunk input to a file in proptest-regressions/. The next run replays that exact case before generating new random data. A flaky property test is almost always a buggy property, not a flaky framework.

If you need strict determinism, you can fix the seed in your test config. Most teams do not bother. The regression file is enough.

Where Property Tests Pay Off

Property-based testing is worth the overhead when your code has clear mathematical invariants and a large input space.

Sorting is the textbook example. The properties are simple: the output is sorted, it is a permutation of the input, and it has the same length. A buggy sort might pass hand-written examples but fail on random data with duplicate elements.

Parsers and serializers are another sweet spot. Round-trip properties like parse(serialize(x)) == x catch encoding bugs that only appear with specific byte sequences.

Anything with arithmetic, collections, or state transitions benefits. Anything with external APIs, strict timing, or human judgment does not.

Start with One Property

You do not need to rewrite your test suite. Pick one pure function with a clear invariant. Add proptest to dev-dependencies. Write a single property. Run it, watch it pass, then introduce a subtle bug and watch it shrink the failure to something you can debug in seconds.

Once you see a random test find a bug you would never have written an example for, you will start reaching for properties more often. Not for every test. Just for the ones that matter.