Property-based testing is fundamentally about finding bugs by generating inputs, not by writing them by hand.
Let’s see proptest in action. Imagine we have a simple function that reverses a string, and we want to ensure it’s always correct.
// src/lib.rs
pub fn reverse_string(s: &str) -> String {
s.chars().rev().collect()
}
Now, we’ll write a property test for it using proptest.
// src/lib.rs
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn reverse_is_its_own_inverse(s in any::<String>()) {
let reversed = reverse_string(&s);
let double_reversed = reverse_string(&reversed);
assert_eq!(s, double_reversed);
}
}
}
To run this, you’d first add proptest to your Cargo.toml:
[dependencies]
proptest = "1.0"
Then, simply run cargo test. proptest will generate thousands of different String inputs and run reverse_is_its_own_inverse for each. If it finds a case where s != double_reversed, it will report that specific input that caused the failure.
The core problem proptest solves is the inherent limitation of example-based testing. When you write unit tests, you manually pick specific inputs. You might test an empty string, a short string, a string with spaces, etc. But you can’t possibly anticipate all the edge cases, especially with complex data structures or algorithms. proptest automates the exploration of the input space, making it much more likely to stumble upon those tricky, hard-to-imagine inputs that break your code.
Internally, proptest uses a technique called "shrinking." When it finds an input that causes a test to fail, it doesn’t just give you that large, complex input. It repeatedly tries to simplify it, removing parts while still causing the failure. This results in the smallest possible failing input, which is incredibly valuable for debugging. For example, if your test fails on a 10,000-character string, shrinking might reduce it to "\n\r" or a similarly simple, yet problematic, input.
The proptest! macro is the entry point. The any::<T>() part tells proptest to generate values of type T. You can customize generation significantly. For instance, if you only wanted to test strings with lowercase letters, you could use "[a-z]*" as a strategy.
proptest! {
#[test]
fn reverse_lowercase_only(s in "[a-z]*") {
// ... test logic ...
}
}
This allows you to tailor the input generation to the specific constraints of your function’s domain. You can also combine strategies, create custom ones, and define relationships between generated values.
The "property" in "property-based testing" refers to a general invariant that should hold true for your code. Instead of testing specific outputs for specific inputs, you test a general rule. For reverse_string, the property is "reversing a string twice should result in the original string." This abstract thinking is key to unlocking the power of this testing paradigm.
Most people understand proptest for generating basic types like integers or strings. What many don’t realize is how powerful it is for complex, nested data structures. You can define custom Arbitrary implementations for your structs and enums, allowing proptest to generate intricate combinations of your application’s data. This is where it truly shines, uncovering bugs in serialization/deserialization, state machines, or complex parsing logic that manual test cases would likely miss.
The next step in your property-based testing journey will likely involve exploring more advanced strategy combinators and custom data generation.