moodmosaic

Property-based testing in Rust and Go

This article is part of a four-part series on fuzzing in memory-safe languages.

Property-based testing shifts focus from “does this crash?” to “does this behave correctly?” by specifying invariants and generating test cases to validate them.

From QuickCheck to Modern Languages

Haskell’s QuickCheck pioneered property-based testing: instead of writing specific test cases, you define properties that should hold for all inputs, then let the framework generate hundreds of test cases.

This approach maps naturally to Rust and Go’s strong type systems, where properties can be expressed as precise assertions about program behavior.

Rust: proptest and QuickCheck

Rust offers several property-testing libraries. proptest provides flexible input generation:

use proptest::prelude::*;

proptest! {
    #[test]
    fn reverse_twice_is_identity(ref vec in prop::collection::vec(any::<i32>(), 0..100)) {
        let mut v = vec.clone();
        v.reverse();
        v.reverse();
        prop_assert_eq!(&v, vec);
    }
}

The quickcheck crate offers a more direct port of the original:

#[cfg(test)]
mod tests {
    use quickcheck_macros::quickcheck;

    #[quickcheck]
    fn prop_sort_idempotent(mut xs: Vec<i32>) -> bool {
        let sorted_once = {
            xs.sort();
            xs.clone()
        };
        xs.sort();
        xs == sorted_once
    }
}

That’s the idempotence property of sorting: sorting a sequence twice should yield the same result as sorting once.

Go: testing/quick and Third-Party Libraries

Go’s standard library includes testing/quick for basic property testing:

func TestReverseProperty(t *testing.T) {
    f := func(slice []int) bool {
        original := make([]int, len(slice))
        copy(original, slice)

        reverse(slice)
        reverse(slice)

        return reflect.DeepEqual(slice, original)
    }

    if err := quick.Check(f, nil); err != nil {
        t.Error(err)
    }
}

Third-party libraries like Gopter provide generators for common types and support custom generators. Shrinking happens automatically when a test fails, producing a minimal counterexample:

func TestConcatPreservesLength(t *testing.T) {
    parameters := gopter.DefaultTestParameters()
    properties := gopter.NewProperties(parameters)

    properties.Property("concat preserves total length", prop.ForAll(
        func(a, b []int) bool {
            concatenated := append(a, b...)
            return len(concatenated) == len(a)+len(b)
        },
        gen.SliceOf(gen.Int()),
        gen.SliceOf(gen.Int()),
    ))

    // If this property fails, Gopter will attempt to shrink `a` and `b`
    // to the smallest slices that still demonstrate the failure.
    properties.TestingRun(t)
}

Shrinking: Finding Minimal Counterexamples

When properties fail, frameworks automatically “shrink” the failing input to find the smallest case that reproduces the bug:

Original failing input: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Shrunk to: [1, 2]

This dramatically speeds debugging by eliminating irrelevant complexity from test failures.

Properties vs Examples

Property-based tests excel at catching edge cases that example-based tests miss:

// Example-based: tests specific cases.
#[test]
fn test_parse_specific_urls() {
    assert!(parse_url("http://example.com").is_ok());
    assert!(parse_url("invalid").is_err());
}

// Property-based: tests general behavior.
proptest! {
    #[test]
    fn prop_parse_roundtrip(url in valid_url_strategy()) {
        let parsed1 = parse_url(&url).unwrap();
        let serialized = parsed1.to_string();
        let parsed2 = parse_url(&serialized).unwrap();
        prop_assert_eq!(parsed1, parsed2);
    }
}

When to Use Property Testing

Property testing works best when you can articulate invariants:

For security-critical code or complex algorithms, property tests provide higher confidence than example-based tests alone.

Integration with Fuzzing

Property-based testing complements fuzzing. While cargo-fuzz explores the input space, property tests validate the expected behavior.

Integration with Development Workflow

Property tests integrate naturally into CI pipelines:

# Rust: Run property tests (default is 256 cases per property, configurable).
cargo test

# Go: Run property tests (iteration count is set in code, not via CLI flag).
go test

The deterministic nature (given the same seed) makes property tests suitable for regression testing, while their thoroughness catches bugs early in development.


Next: Fuzzing meets property testing