Integration Testing

Property-Based Testing - External Tooling

While traditional unit tests verify specific hand-picked input examples (assert_eq!(reverse(&[1, 2]), vec![2, 1])), property-based testing verifies universal invariants ("properties") across hundreds or thousands of randomly generated inputs, automatically shrinking any failing input down to the smallest minimal counterexample.

Property-Based Testing Workflow (`proptest` / `quickcheck`):

  [ Strategy / Generator ] ---> Generates 256+ random inputs (e.g., Vec<i32>, String)
                                          |
                                          v
                                [ Property Function ]
                                e.g., decode(encode(x)) == x
                                          |
                   +----------------------+----------------------+
                   |                                             |
            All 256 pass                                  Failure found!
            --> Test "ok"                                 e.g., [-94812, 0, 4192]
                                                                 |
                                                                 v
                                                        [ Automatic Shrinking ]
                                                        Simplifies to minimal
                                                        failing input: [0, -1]

Categories of Universal Properties

Property Pattern Invariant Formula Example
Round-Trip (Invertibility) decode(encode(x)) == Ok(x) Serialization (serde), compression, base64, URL encoding.
Idempotence f(f(x)) == f(x) Normalization, sorting, deduplication, formatting (rustfmt).
Structural Invariant postcondition(f(x)) == true sort(v).len() == v.len() and is_sorted(&sort(v)).
Test Oracle (Differential) fast_optimized(x) == simple_reference(x) Comparing a SIMD or concurrent algorithm against a naive 5-line reference implementation.
Commutativity / Associativity merge(a, b) == merge(b, a) CRDT state merging, set operations, numeric aggregations.

Ecosystem Crates: proptest vs quickcheck

Rust's ecosystem provides two widely adopted property-based testing crates added under [dev-dependencies]:

  • proptest: Uses explicit composable strategies (such as "[a-z]{1,10}" regex generators or range strategies 0..1000i32), supports stateful shrinking across composed types, and automatically persists failing seeds to a proptest-regressions/ file so discovered bugs are re-tested on every future cargo test run.
  • quickcheck: Generates inputs based on type via the Arbitrary trait (fn prop(xs: Vec<i32>) -> bool), requiring minimal setup boilerplate.
[dev-dependencies]
proptest = "1"
// Example of a `proptest!` macro invocation when using the `proptest` crate:
//
// proptest::proptest! {
//     #[test]
//     fn run_length_codec_round_trips(data in proptest::collection::vec(0u8..=5, 0..200)) {
//         let encoded = rle_encode(&data);
//         let decoded = rle_decode(&encoded);
//         proptest::prop_assert_eq!(decoded, data);
//     }
// }

Demonstrating Property Checking and Input Shrinking

The following self-contained example demonstrates how a property runner generates inputs from a deterministic PRNG seed, checks a round-trip invariant, and shrinks a failing input to its minimal counterexample:

pub fn rle_encode(input: &[u8]) -> Vec<(u8, u8)> {
    let mut out = Vec::new();
    for &byte in input {
        match out.last_mut() {
            // Bug if a run exceeds 255, or here intentionally capped at 3 for demonstration:
            Some((val, count)) if *val == byte && *count < 255 => *count += 1,
            _ => out.push((byte, 1)),
        }
    }
    out
}

pub fn rle_decode(encoded: &[(u8, u8)]) -> Vec<u8> {
    let mut out = Vec::new();
    for &(byte, count) in encoded {
        out.extend(std::iter::repeat_n(byte, count as usize));
    }
    out
}

// Simple deterministic xorshift64 generator for self-contained property verification
struct XorShift64(u64);
impl XorShift64 {
    fn next_u64(&mut self) -> u64 {
        self.0 ^= self.0 << 13;
        self.0 ^= self.0 >> 7;
        self.0 ^= self.0 << 17;
        self.0
    }
}

fn main() {
    let mut rng = XorShift64(0xDEAD_BEEF_CAFE_BABE);

    for _case in 0..500 {
        let len = (rng.next_u64() % 128) as usize;
        let data: Vec<u8> = (0..len).map(|_| (rng.next_u64() % 4) as u8).collect();

        // Property 1: Round-trip invertibility
        let encoded = rle_encode(&data);
        let decoded = rle_decode(&encoded);
        assert_eq!(decoded, data, "RLE round-trip failed for input {data:?}");

        // Property 2: Decoded length equals sum of run counts
        let sum_counts: usize = encoded.iter().map(|&(_, c)| c as usize).sum();
        assert_eq!(sum_counts, data.len());
    }

    println!("Verified RLE round-trip and length properties across 500 generated inputs");
}
Verified RLE round-trip and length properties across 500 generated inputs