Skip to content
rusthacks.dev
Testing

Property Based Testing in Rust With Proptest Step by Step

Instead of picking inputs, state a property and let proptest generate hundreds. See round trip and invariant properties, shrinking to a minimal failure and a bug it finds.

3 min read
Summarize in AI
Flat illustration for the post “Property Based Testing in Rust With Proptest Step by Step”, composed around testing.

Examples test what you thought of. Properties test what you did not. A property test says "for any list, sorting twice equals sorting once", and the library finds the list that disagrees. The first run often finds a bug in code the author considered finished. This post shows two properties and the shrinking output. It is part of the testing in Rust guide.

Rust 1.90, proptest 1.

A round trip property

Cargo.tomlTOML
[dev-dependencies]
proptest = "1"
rust
pub fn encode(values: &[u8]) -> String {
    values.iter().map(|b| format!("{b:02x}")).collect()
}

pub fn decode(s: &str) -> Option<Vec<u8>> {
    if s.len() % 2 != 0 {
        return None;
    }
    (0..s.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
        .collect()
}

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

    proptest! {
        #[test]
        fn round_trips(bytes in prop::collection::vec(any::<u8>(), 0..64)) {
            prop_assert_eq!(decode(&encode(&bytes)), Some(bytes));
        }

        #[test]
        fn decode_never_panics(s in "\\PC*") {
            let _ = decode(&s);
        }
    }
}

round_trips generates vectors of up to 63 bytes, encodes and decodes them. decode_never_panics feeds arbitrary printable strings, a regex pattern that means any non-control characters. The second test finds a real bug: &s[i..i + 2] slices by bytes, and a multibyte character makes that index fall inside a character.

A failing case, shrunk

Text
thread 'tests::decode_never_panics' panicked at src/lib.rs:12:25:
byte index 1 is not a char boundary; it is inside 'é' (bytes 0..2) of `é`
minimal failing input: s = "é"

Proptest started with some long Unicode string and reduced it to the single character é. Without shrinking you would get a 40-character string and a guess. The fix is to work on bytes (s.as_bytes()) and validate hex digits. The byte versus character rule is explained in String versus str.

Regression file

After a failure, proptest writes proptest-regressions/lib.txt with the seed. Commit it. Every later run replays those cases first, so a fixed bug cannot return unnoticed.

Writing properties

KindExample
Round tripparse(print(x)) == x
Idempotencenormalize(normalize(x)) == normalize(x)
Invariantoutput length equals input length
Oraclefast version equals the slow, obvious version
No panicany input returns a value or an error

The oracle form is strong for optimization work: keep the simple implementation in the tests and compare.

Custom strategies

Build values for your types with prop_compose! or #[derive(Arbitrary)] from proptest-derive, and constrain them so you generate valid inputs, or valid-but-weird ones. A generator that mostly produces invalid input tests only the error path.

Cost and limits

256 cases is a few milliseconds for pure functions, and slow for anything touching I/O. Use ProptestConfig::with_cases(32) for slow properties. Property tests do not replace examples; they complement them, and a documented example is still the best explanation. For long-running exploration of parsers and unsafe code, use a fuzzer. The documentation is the Proptest Book.

Taggedproptest
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.