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.

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
[dev-dependencies]
proptest = "1"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
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
| Kind | Example |
|---|---|
| Round trip | parse(print(x)) == x |
| Idempotence | normalize(normalize(x)) == normalize(x) |
| Invariant | output length equals input length |
| Oracle | fast version equals the slow, obvious version |
| No panic | any 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.


