๐Ÿฆ€ Functional Rust

748: Property-Based Testing (proptest Pattern)

Difficulty: 3 Level: Advanced Instead of specific inputs, describe properties that must always hold โ€” the framework generates random inputs and finds counterexamples, then shrinks them to the minimal failing case.

The Problem This Solves

Example-based testing has a blind spot: you only test the inputs you think of. You test `sort([3,1,2])` but not `sort([])`, `sort([i32::MIN])`, or a 10,000-element list with duplicates. Bugs hide in the corners you didn't imagine. Property-based testing flips the model. Instead of writing specific inputs, you describe invariants โ€” facts that must be true for all valid inputs. "Sorting a list always produces a result of the same length." "The output is always non-decreasing." "Sorting twice equals sorting once." Then a generator creates hundreds of random inputs and checks each property. When a property fails, the framework shrinks the counterexample: it systematically simplifies the failing input until it finds the minimal case that still fails. Instead of "your sort fails on this 47-element list", you get "your sort fails on `[-1, 0]`". That's dramatically easier to debug.

The Intuition

In Python, the `hypothesis` library does this: you write `@given(st.lists(st.integers()))` and Hypothesis generates inputs. In JavaScript, `fast-check` is equivalent. The real-world crate in Rust is [`proptest`](https://docs.rs/proptest). This example shows the pattern from scratch using a simple PRNG and an `Arbitrary` trait โ€” so you understand what proptest does internally. The core loop: pick a random input, check the property, if it fails, try smaller versions of the input, report the minimal failure.

How It Works in Rust

// An Arbitrary type can generate random examples and shrink failures
trait Arbitrary: Sized + Clone + std::fmt::Debug {
 fn arbitrary(rng: &mut Lcg) -> Self;
 fn shrink(&self) -> Vec<Self> { vec![] }  // default: no shrinking
}

impl Arbitrary for Vec<i32> {
 fn arbitrary(rng: &mut Lcg) -> Self {
     let len = rng.next_usize_in(0, 20);
     (0..len).map(|_| i32::arbitrary(rng)).collect()
 }
 fn shrink(&self) -> Vec<Vec<i32>> {
     // Return simpler candidates: remove first, remove last, halve
     vec![self[1..].to_vec(), self[..self.len()-1].to_vec()]
 }
}

// The property runner: generate N inputs, check the predicate,
// shrink and report the minimal counterexample on failure
fn forall<T, F>(name: &str, tests: usize, mut prop: F) -> bool
where T: Arbitrary, F: FnMut(&T) -> bool
{
 let mut rng = Lcg::new(42);
 for i in 0..tests {
     let input = T::arbitrary(&mut rng);
     if !prop(&input) {
         // Shrink: find the simplest failing input
         let mut minimal = input.clone();
         loop {
             let smaller = minimal.shrink().into_iter().find(|c| !prop(c));
             match smaller {
                 Some(s) => minimal = s,
                 None    => break,
             }
         }
         eprintln!("โœ— {name} failed after {} tests. Minimal: {:?}", i+1, minimal);
         return false;
     }
 }
 true
}

// Properties of sort โ€” all three should hold for any input
fn my_sort(mut v: Vec<i32>) -> Vec<i32> { v.sort(); v }

// In tests:
#[test]
fn property_sort_idempotent() {
 assert!(forall::<Vec<i32>, _>("sort(sort(x)) == sort(x)",
     500, |v| my_sort(my_sort(v.clone())) == my_sort(v.clone())));
}

#[test]
fn property_sort_length_preserved() {
 assert!(forall::<Vec<i32>, _>("sort preserves length",
     500, |v| my_sort(v.clone()).len() == v.len()));
}
With the real `proptest` crate, this becomes:
use proptest::prelude::*;

proptest! {
 #[test]
 fn sort_idempotent(v: Vec<i32>) {
     let sorted = { let mut v = v.clone(); v.sort(); v };
     let sorted_twice = { let mut s = sorted.clone(); s.sort(); s };
     assert_eq!(sorted, sorted_twice);
 }
}

What This Unlocks

Key Differences

ConceptOCamlRust
Property testing libraryQCheck`proptest` or `quickcheck` crates
Generator definition`QCheck.Gen.t``Arbitrary` trait (or proptest `Strategy`)
ShrinkingAutomatic via `QCheck.shrink`Explicit `shrink()` method or proptest auto-shrink
Test macro`QCheck.Test.make``proptest!` macro or manual `forall`
Seed control`QCheck.Random.State`Configurable; proptest saves failing seeds
Example this showsN/AManual PRNG + Arbitrary โ€” no crate needed
/// 748: Property-Based Testing โ€” std-only QuickCheck-style framework

// โ”€โ”€ Simple deterministic PRNG (LCG) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

struct Lcg(u64);

impl Lcg {
    fn new(seed: u64) -> Self { Lcg(seed) }

    fn next_u64(&mut self) -> u64 {
        // LCG: Numerical Recipes constants
        self.0 = self.0.wrapping_mul(6364136223846793005)
            .wrapping_add(1442695040888963407);
        self.0
    }

    fn next_i32_in(&mut self, lo: i32, hi: i32) -> i32 {
        let range = (hi - lo + 1) as u64;
        lo + (self.next_u64() % range) as i32
    }

    fn next_usize_in(&mut self, lo: usize, hi: usize) -> usize {
        let range = (hi - lo + 1) as u64;
        lo + (self.next_u64() % range) as usize
    }
}

// โ”€โ”€ Arbitrary trait โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

trait Arbitrary: Sized + Clone + std::fmt::Debug {
    fn arbitrary(rng: &mut Lcg) -> Self;
    /// Shrink to simpler values (for counterexample minimisation)
    fn shrink(&self) -> Vec<Self> { vec![] }
}

impl Arbitrary for i32 {
    fn arbitrary(rng: &mut Lcg) -> Self {
        rng.next_i32_in(-1000, 1000)
    }
    fn shrink(&self) -> Vec<i32> {
        if *self == 0 { return vec![]; }
        vec![0, self / 2, self.abs() - 1]
            .into_iter()
            .filter(|&x| x.abs() < self.abs())
            .collect()
    }
}

impl Arbitrary for Vec<i32> {
    fn arbitrary(rng: &mut Lcg) -> Self {
        let len = rng.next_usize_in(0, 20);
        (0..len).map(|_| i32::arbitrary(rng)).collect()
    }
    fn shrink(&self) -> Vec<Vec<i32>> {
        let mut shrunk = vec![];
        if self.is_empty() { return shrunk; }
        // Remove first element
        shrunk.push(self[1..].to_vec());
        // Remove last element
        shrunk.push(self[..self.len()-1].to_vec());
        // Halve
        shrunk.push(self[..self.len()/2].to_vec());
        shrunk
    }
}

// โ”€โ”€ forall: property checker with shrinking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fn forall<T, F>(name: &str, tests: usize, mut prop: F) -> bool
where
    T: Arbitrary,
    F: FnMut(&T) -> bool,
{
    let mut rng = Lcg::new(42);
    for i in 0..tests {
        let input = T::arbitrary(&mut rng);
        if !prop(&input) {
            // Try to shrink the counterexample
            let mut minimal = input.clone();
            loop {
                let candidates = minimal.shrink();
                let smaller = candidates.into_iter().find(|c| !prop(c));
                match smaller {
                    Some(s) => minimal = s,
                    None    => break,
                }
            }
            eprintln!("โœ— {} failed after {} tests. Counterexample: {:?}", name, i+1, minimal);
            return false;
        }
    }
    println!("โœ“ {} โ€” {} tests passed", name, tests);
    true
}

// โ”€โ”€ Functions under test โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fn my_sort(mut v: Vec<i32>) -> Vec<i32> {
    v.sort();
    v
}

fn is_sorted(v: &[i32]) -> bool {
    v.windows(2).all(|w| w[0] <= w[1])
}

fn sum_commutative(a: i32, b: i32) -> bool {
    a.wrapping_add(b) == b.wrapping_add(a)
}

fn main() {
    // Properties of sort
    forall::<Vec<i32>, _>("sort is idempotent",
        1000, |v| my_sort(my_sort(v.clone())) == my_sort(v.clone()));

    forall::<Vec<i32>, _>("sort preserves length",
        1000, |v| my_sort(v.clone()).len() == v.len());

    forall::<Vec<i32>, _>("sort result is ordered",
        1000, |v| is_sorted(&my_sort(v.clone())));

    // Property of addition
    let mut rng = Lcg::new(99);
    let mut ok = true;
    for _ in 0..1000 {
        let a = i32::arbitrary(&mut rng);
        let b = i32::arbitrary(&mut rng);
        ok &= sum_commutative(a, b);
    }
    println!("{} addition is commutative โ€” 1000 tests passed", if ok { "โœ“" } else { "โœ—" });
}

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

    #[test]
    fn lcg_produces_different_values() {
        let mut rng = Lcg::new(1);
        let a = rng.next_u64();
        let b = rng.next_u64();
        assert_ne!(a, b);
    }

    #[test]
    fn property_sort_idempotent() {
        assert!(forall::<Vec<i32>, _>("sort idempotent",
            500, |v| my_sort(my_sort(v.clone())) == my_sort(v.clone())));
    }

    #[test]
    fn property_sort_length_preserved() {
        assert!(forall::<Vec<i32>, _>("sort length",
            500, |v| my_sort(v.clone()).len() == v.len()));
    }

    #[test]
    fn property_sort_ordered() {
        assert!(forall::<Vec<i32>, _>("sort ordered",
            500, |v| is_sorted(&my_sort(v.clone()))));
    }

    #[test]
    fn i32_shrink_produces_smaller() {
        for x in [100i32, -50, 17] {
            for smaller in x.shrink() {
                assert!(smaller.abs() < x.abs(),
                    "{} should shrink below {}", smaller, x);
            }
        }
    }

    #[test]
    fn vec_shrink_shorter() {
        let v = vec![1, 2, 3, 4, 5];
        let shrunk = v.shrink();
        assert!(!shrunk.is_empty());
        for s in shrunk {
            assert!(s.len() < v.len(), "shrunk {:?} should be shorter than {:?}", s, v);
        }
    }
}
(* 748: Property-Based Testing โ€” OCaml minimal QuickCheck *)

(* Simple LCG random number generator *)
let seed = ref 12345

let next_int () =
  seed := (!seed * 1664525 + 1013904223) land 0x7FFFFFFF;
  !seed

let rand_int lo hi =
  lo + (next_int () mod (hi - lo + 1))

let rand_list gen lo hi max_len =
  let len = rand_int 0 max_len in
  List.init len (fun _ -> gen lo hi)

(* Property: for all lists, sort is idempotent *)
let prop_sort_idempotent lst =
  let sorted1 = List.sort compare lst in
  let sorted2 = List.sort compare sorted1 in
  sorted1 = sorted2

(* Property: sort preserves length *)
let prop_sort_length lst =
  List.length (List.sort compare lst) = List.length lst

(* Property: sort result is ordered *)
let prop_sort_ordered lst =
  let sorted = List.sort compare lst in
  let rec check = function
    | [] | [_] -> true
    | x :: y :: rest -> x <= y && check (y :: rest)
  in
  check sorted

(* forall: run property on N random inputs *)
let forall n gen prop name =
  let failed = ref 0 in
  for _ = 1 to n do
    let input = gen (-100) 100 20 in
    if not (prop input) then incr failed
  done;
  if !failed = 0
  then Printf.printf "โœ“ %s: %d tests passed\n" name n
  else Printf.printf "โœ— %s: %d/%d FAILED\n" name !failed n

let () =
  forall 1000 (rand_list rand_int) prop_sort_idempotent "sort is idempotent";
  forall 1000 (rand_list rand_int) prop_sort_length    "sort preserves length";
  forall 1000 (rand_list rand_int) prop_sort_ordered   "sort result is ordered"