As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Property-based testing expands our testing approach beyond fixed examples to verify code behavior across a wide range of inputs. It's particularly effective for finding edge cases and unexpected behaviors that traditional tests often miss.
In Rust, several frameworks enable property-based testing, with proptest being the most widely adopted. This approach verifies that your code maintains certain properties regardless of the input provided, rather than testing specific cases.
When I first incorporated property-based testing into my workflow, I was surprised by how many subtle bugs it uncovered in code that had already passed traditional unit tests. It fundamentally changed my approach to quality assurance.
Property-based testing works by generating random inputs that meet specific criteria, then checking whether your code's behavior satisfies the defined properties when processing these inputs. When failures occur, the testing framework automatically "shrinks" the failing input to its simplest form, making debugging much more efficient.
The core concept revolves around properties - assertions about how your code should behave given any valid input. For example, a sorting function should always produce output that's ordered and contains exactly the same elements as the input.
To start using property-based testing in Rust, add the proptest crate to your Cargo.toml:
[dev-dependencies]
proptest = "1.0"
Let's examine a basic example that tests a simple string reversal function:
use proptest::prelude::*;
fn reverse_string(input: &str) -> String {
input.chars().rev().collect()
}
proptest! {
#[test]
fn reversing_twice_yields_original_string(s in ".*") {
let once = reverse_string(&s);
let twice = reverse_string(&once);
assert_eq!(s, twice);
}
}
This test verifies a fundamental property of string reversal: applying it twice should return the original string. The framework will generate various strings, including empty strings, Unicode characters, and extremely long strings.
Property-based testing truly shines when working with complex data structures and algorithms. Consider testing a custom binary tree implementation:
use proptest::prelude::*;
#[derive(Debug, Clone, PartialEq)]
enum BinaryTree<T> {
Node(T, Box<BinaryTree<T>>, Box<BinaryTree<T>>),
Empty,
}
impl<T: Ord> BinaryTree<T> {
fn insert(&self, value: T) -> Self {
match self {
BinaryTree::Empty => BinaryTree::Node(
value,
Box::new(BinaryTree::Empty),
Box::new(BinaryTree::Empty)
),
BinaryTree::Node(v, left, right) => {
if value < *v {
BinaryTree::Node(*v, Box::new(left.insert(value)), right.clone())
} else if value > *v {
BinaryTree::Node(*v, left.clone(), Box::new(right.insert(value)))
} else {
self.clone() // Value already exists
}
}
}
}
fn contains(&self, value: &T) -> bool {
match self {
BinaryTree::Empty => false,
BinaryTree::Node(v, left, right) => {
if value < v {
left.contains(value)
} else if value > v {
right.contains(value)
} else {
true
}
}
}
}
}
proptest! {
#[test]
fn inserted_values_are_contained(values in prop::collection::vec(1..100i32, 1..20)) {
let mut tree = BinaryTree::Empty;
for &value in &values {
tree = tree.insert(value);
assert!(tree.contains(&value), "Tree should contain inserted value {}", value);
}
for &value in &values {
assert!(tree.contains(&value), "Tree should still contain value {}", value);
}
}
}
This test ensures that after inserting values into the tree, they can all be found with the contains method. The framework will generate various arrays of integers with different lengths and contents.
Another powerful feature of property-based testing is the ability to define custom generators for complex data structures:
use proptest::prelude::*;
#[derive(Debug, Clone)]
struct User {
id: u64,
name: String,
email: String,
active: bool,
}
// Define a strategy to generate valid User instances
fn user_strategy() -> impl Strategy<Value = User> {
(
any::<u64>(),
"[a-zA-Z0-9]{1,10}", // Simple name
"[a-z0-9]{1,10}@[a-z]{2,6}\\.[a-z]{2,4}", // Simple email
any::<bool>(),
).prop_map(|(id, name, email, active)| User {
id, name, email, active
})
}
fn process_user(user: &User) -> String {
format!("User {} ({}) is {}",
user.name,
user.email,
if user.active { "active" } else { "inactive" })
}
proptest! {
#[test]
fn processed_user_contains_name_and_email(user in user_strategy()) {
let result = process_user(&user);
assert!(result.contains(&user.name), "Result should contain user name");
assert!(result.contains(&user.email), "Result should contain user email");
// Check status is correctly reflected
if user.active {
assert!(result.contains("active"), "Active user should be marked as active");
} else {
assert!(result.contains("inactive"), "Inactive user should be marked as inactive");
}
}
}
When testing functions that can panic under certain conditions, proptest provides tools to test this behavior explicitly:
use proptest::prelude::*;
fn divide(a: i32, b: i32) -> i32 {
a / b // This will panic if b is 0
}
proptest! {
#[test]
fn division_works_for_non_zero_denominators(a in any::<i32>(), b in prop::num::i32::ANY.prop_filter(
"b cannot be zero", |&b| b != 0
)) {
let result = divide(a, b);
prop_assert_eq!(result, a / b);
}
#[test]
fn division_by_zero_panics(a in any::<i32>()) {
prop_assert_eq!(std::panic::catch_unwind(|| divide(a, 0)).is_err(), true);
}
}
For more sophisticated applications, you might need to test stateful systems. The proptest crate supports this with state machine testing:
use proptest::prelude::*;
use proptest::test_runner::TestCaseError;
#[derive(Debug, Clone)]
struct Counter {
value: u32,
max_value: u32,
}
impl Counter {
fn new(max_value: u32) -> Self {
Counter { value: 0, max_value }
}
fn increment(&mut self) -> Result<(), &'static str> {
if self.value >= self.max_value {
return Err("Cannot increment past maximum");
}
self.value += 1;
Ok(())
}
fn decrement(&mut self) -> Result<(), &'static str> {
if self.value == 0 {
return Err("Cannot decrement below zero");
}
self.value -= 1;
Ok(())
}
fn get_value(&self) -> u32 {
self.value
}
}
#[derive(Debug, Clone)]
enum CounterOperation {
Increment,
Decrement,
}
proptest! {
#[test]
fn counter_operations_maintain_invariants(
max_value in 1..100u32,
operations in prop::collection::vec(prop::sample::select(vec![
CounterOperation::Increment,
CounterOperation::Decrement,
]), 0..100)
) {
let mut counter = Counter::new(max_value);
let mut expected_value = 0;
for op in operations {
match op {
CounterOperation::Increment => {
let result = counter.increment();
if expected_value < max_value {
expected_value += 1;
assert!(result.is_ok(), "Increment should succeed when below max");
} else {
assert!(result.is_err(), "Increment should fail when at max");
}
},
CounterOperation::Decrement => {
let result = counter.decrement();
if expected_value > 0 {
expected_value -= 1;
assert!(result.is_ok(), "Decrement should succeed when above zero");
} else {
assert!(result.is_err(), "Decrement should fail when at zero");
}
},
}
assert_eq!(counter.get_value(), expected_value,
"Counter value should match expected value after operations");
}
}
}
For parsing and serialization code, property testing can verify round-trip properties:
use proptest::prelude::*;
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Product {
id: u32,
name: String,
price: f64,
in_stock: bool,
}
fn product_strategy() -> impl Strategy<Value = Product> {
(
any::<u32>(),
"[a-zA-Z0-9 ]{1,20}",
(1..10000).prop_map(|n| n as f64 / 100.0),
any::<bool>(),
).prop_map(|(id, name, price, in_stock)| Product {
id, name, price, in_stock
})
}
proptest! {
#[test]
fn json_serialization_roundtrip(product in product_strategy()) {
// Serialize to JSON
let json = serde_json::to_string(&product).expect("Failed to serialize");
// Deserialize from JSON
let deserialized: Product = serde_json::from_str(&json).expect("Failed to deserialize");
// Verify we got back the same product
assert_eq!(product, deserialized, "Product changed during serialization roundtrip");
}
}
Property testing also helps identify performance regressions by checking complexity properties:
use proptest::prelude::*;
use std::time::{Instant, Duration};
fn search_sorted_array(array: &[i32], target: i32) -> Option<usize> {
// Binary search implementation
let mut low = 0;
let mut high = array.len();
while low < high {
let mid = low + (high - low) / 2;
match array[mid].cmp(&target) {
std::cmp::Ordering::Equal => return Some(mid),
std::cmp::Ordering::Greater => high = mid,
std::cmp::Ordering::Less => low = mid + 1,
}
}
None
}
proptest! {
#[test]
fn binary_search_is_logarithmic(
// Generate sorted arrays of different sizes
size in (10u32..20).prop_map(|n| 2u32.pow(n))
) {
// Create a sorted array of the given size
let array: Vec<i32> = (0..size as i32).collect();
// Time the search operation
let start = Instant::now();
let _ = search_sorted_array(&array, size as i32 / 2);
let duration = start.elapsed();
// For logarithmic complexity, doubling the input size should add roughly a constant time
// We can check by ensuring time doesn't grow too quickly with input size
prop_assert!(
duration < Duration::from_millis(100),
"Search took too long for size {}: {:?}", size, duration
);
}
}
To find concurrency issues, property testing can be combined with tools like loom:
use proptest::prelude::*;
#[derive(Clone)]
struct AtomicCounter {
value: std::sync::atomic::AtomicUsize,
}
impl AtomicCounter {
fn new() -> Self {
AtomicCounter {
value: std::sync::atomic::AtomicUsize::new(0),
}
}
fn increment(&self) {
self.value.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
fn get(&self) -> usize {
self.value.load(std::sync::atomic::Ordering::SeqCst)
}
}
proptest! {
#[test]
fn concurrent_increments_are_accurate(threads in 1..10usize, increments in 1..100usize) {
let counter = std::sync::Arc::new(AtomicCounter::new());
let mut handles = vec![];
for _ in 0..threads {
let counter_clone = counter.clone();
let thread_increments = increments;
handles.push(std::thread::spawn(move || {
for _ in 0..thread_increments {
counter_clone.increment();
}
}));
}
for handle in handles {
handle.join().unwrap();
}
assert_eq!(counter.get(), threads * increments);
}
}
When working with file I/O, property testing can generate temporary files and verify file operations:
use proptest::prelude::*;
use std::io::{Read, Write};
use std::fs::{self, File};
use tempfile::tempdir;
proptest! {
#[test]
fn file_write_read_roundtrip(data in prop::collection::vec(any::<u8>(), 0..10000)) {
// Create a temporary directory
let dir = tempdir().expect("Failed to create temp dir");
let file_path = dir.path().join("test_file.bin");
// Write data to file
{
let mut file = File::create(&file_path).expect("Failed to create file");
file.write_all(&data).expect("Failed to write data");
}
// Read data back
let mut read_data = Vec::new();
{
let mut file = File::open(&file_path).expect("Failed to open file");
file.read_to_end(&mut read_data).expect("Failed to read data");
}
// Verify data is unchanged
assert_eq!(data, read_data, "Data changed during file I/O roundtrip");
}
}
Property-based testing excels at uncovering bugs in algorithms with complex invariants. Here's an example testing a custom HashMap implementation:
use proptest::prelude::*;
use std::collections::HashMap;
// A simplified custom HashMap implementation (for demonstration)
#[derive(Debug, Clone)]
struct MyHashMap<K, V> {
buckets: Vec<Vec<(K, V)>>,
size: usize,
}
impl<K: Eq + std::hash::Hash + Clone, V: Clone> MyHashMap<K, V> {
fn new() -> Self {
MyHashMap {
buckets: vec![Vec::new(); 16],
size: 0,
}
}
fn insert(&mut self, key: K, value: V) -> Option<V> {
let bucket_idx = self.get_bucket_index(&key);
let bucket = &mut self.buckets[bucket_idx];
for i in 0..bucket.len() {
if bucket[i].0 == key {
let old_value = bucket[i].1.clone();
bucket[i] = (key, value);
return Some(old_value);
}
}
bucket.push((key, value));
self.size += 1;
None
}
fn get(&self, key: &K) -> Option<&V> {
let bucket_idx = self.get_bucket_index(key);
let bucket = &self.buckets[bucket_idx];
for (k, v) in bucket {
if k == key {
return Some(v);
}
}
None
}
fn remove(&mut self, key: &K) -> Option<V> {
let bucket_idx = self.get_bucket_index(key);
let bucket = &mut self.buckets[bucket_idx];
for i in 0..bucket.len() {
if &bucket[i].0 == key {
let (_, value) = bucket.remove(i);
self.size -= 1;
return Some(value);
}
}
None
}
fn len(&self) -> usize {
self.size
}
fn get_bucket_index(&self, key: &K) -> usize {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut hasher);
(hasher.finish() as usize) % self.buckets.len()
}
}
// Define actions to perform on the HashMap
#[derive(Debug, Clone)]
enum MapAction<K, V> {
Insert(K, V),
Get(K),
Remove(K),
}
proptest! {
#[test]
fn hashmap_matches_standard_implementation(
actions in prop::collection::vec(
prop::strategy::Union::new(vec![
prop::strategy::Just(1).prop_flat_map(|_| {
(any::<u8>(), any::<String>()).prop_map(|(k, v)| MapAction::Insert(k, v))
}).boxed(),
prop::strategy::Just(1).prop_flat_map(|_| {
any::<u8>().prop_map(|k| MapAction::Get(k))
}).boxed(),
prop::strategy::Just(1).prop_flat_map(|_| {
any::<u8>().prop_map(|k| MapAction::Remove(k))
}).boxed(),
]),
0..100
)
) {
let mut my_map = MyHashMap::new();
let mut std_map = HashMap::new();
for action in actions {
match action {
MapAction::Insert(key, value) => {
let my_result = my_map.insert(key, value.clone());
let std_result = std_map.insert(key, value);
assert_eq!(my_result, std_result, "Insert returned different results");
},
MapAction::Get(key) => {
let my_result = my_map.get(&key);
let std_result = std_map.get(&key);
assert_eq!(my_result, std_result, "Get returned different results");
},
MapAction::Remove(key) => {
let my_result = my_map.remove(&key);
let std_result = std_map.remove(&key);
assert_eq!(my_result, std_result, "Remove returned different results");
},
}
assert_eq!(my_map.len(), std_map.len(), "Maps have different sizes");
}
}
}
In my experience, incorporating property-based testing has dramatically improved the robustness of my code. By forcing me to think in terms of invariants and properties rather than specific examples, it has strengthened both my code and my understanding of its behavior.
Property testing doesn't replace traditional unit testing but complements it by exploring input spaces systematically. The combination provides a more comprehensive testing strategy that catches a wider range of bugs, including those that might only appear in production with unusual inputs.
The initial investment in learning and setting up property-based tests pays off quickly when you discover subtle issues that would have been difficult to find through manual test case design. For complex systems and libraries, it's an essential tool in the modern developer's quality assurance arsenal.
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)