Rust

Property-Based Testing

Generate thousands of test cases automatically with proptest

Property-Based Testing with proptest

Unit tests verify specific examples. Property-based tests verify rules — and they generate thousands of random inputs to find the edge cases you'd never think of.

Why Property-Based Testing?

Consider a dB-to-linear conversion function. You might write:

#[test]
fn test_db_to_linear() {
    assert!((db_to_linear(0.0) - 1.0).abs() < 1e-10);
    assert!((db_to_linear(10.0) - 10.0).abs() < 1e-10);
    assert!((db_to_linear(20.0) - 100.0).abs() < 1e-10);
}

These three cases pass, but what about negative dB values? Extremely large inputs? Values near floating-point boundaries?

Property-based testing lets you express the invariant instead:

"For any dB value, converting to linear and back should return the original value."

The framework generates hundreds of random inputs and checks this property holds for all of them.

Installation

Add proptest as a dev dependency:

Cargo.toml
[dev-dependencies]
proptest = "1"

Basic Usage

use proptest::prelude::*;

proptest! {
    #[test]
    fn roundtrip_db_linear(db in -200.0f64..200.0) {
        let linear = 10.0_f64.powf(db / 10.0);
        let back = 10.0 * linear.log10();
        prop_assert!((back - db).abs() < 1e-9,
            "Roundtrip failed: {} -> {} -> {}", db, linear, back);
    }
}

This generates 256 random db values (configurable) in the range [-200, 200] and checks the roundtrip property for each.

Strategies — Controlling Input Generation

Strategies define how inputs are generated. proptest provides built-in strategies for all standard types:

proptest! {
    #[test]
    fn frequency_is_positive(freq in 1.0e6f64..100.0e9) {
        // freq is always between 1 MHz and 100 GHz
        prop_assert!(freq > 0.0);
    }

    #[test]
    fn vec_of_gains(gains in prop::collection::vec(-30.0f64..40.0, 1..20)) {
        // 1 to 20 gain values, each between -30 and +40 dB
        prop_assert!(!gains.is_empty());
    }
}

Custom Strategies

For domain-specific types, compose strategies:

use proptest::prelude::*;

#[derive(Debug, Clone)]
struct RfBlock {
    gain_db: f64,
    noise_figure_db: f64,
}

fn rf_block_strategy() -> impl Strategy<Value = RfBlock> {
    (-10.0f64..40.0, 0.5f64..15.0).prop_map(|(gain, nf)| RfBlock {
        gain_db: gain,
        noise_figure_db: nf,
    })
}

proptest! {
    #[test]
    fn noise_figure_always_positive(block in rf_block_strategy()) {
        prop_assert!(block.noise_figure_db > 0.0,
            "NF must be positive, got {}", block.noise_figure_db);
    }
}

Shrinking — Finding Minimal Failures

When proptest finds a failing input, it automatically shrinks it to the simplest case that still fails. This is one of the most powerful features.

For example, if a function fails on input [3.7, -128.4, 99.1, 0.003, -42.8], proptest might shrink it down to [-128.4] — revealing that the real issue is a single large negative value.

You don't need to do anything to enable shrinking — it happens automatically.

Real-World Patterns

Inverse Function Pairs

The most common pattern in scientific computing: verify that inverse operations cancel out.

proptest! {
    #[test]
    fn db_linear_roundtrip(val in 0.001f64..1e12) {
        let db = 10.0 * val.log10();
        let back = 10.0_f64.powf(db / 10.0);
        let rel_err = ((back - val) / val).abs();
        prop_assert!(rel_err < 1e-10, "Relative error {} at val={}", rel_err, val);
    }
}

Monotonicity

Some functions should always increase (or decrease) with their input:

proptest! {
    #[test]
    fn free_space_loss_increases_with_distance(
        d1 in 1.0f64..1e6,
        delta in 0.001f64..1e6,
    ) {
        let d2 = d1 + delta;
        let freq = 12.0e9; // 12 GHz
        let loss1 = free_space_loss(d1, freq);
        let loss2 = free_space_loss(d2, freq);
        prop_assert!(loss2 >= loss1,
            "Loss should increase with distance: d1={}, d2={}, loss1={}, loss2={}",
            d1, d2, loss1, loss2);
    }
}

Symmetry

Operations that should be symmetric or commutative:

proptest! {
    #[test]
    fn cascade_order_independent_for_noise_factor(
        nf1 in 1.01f64..100.0,
        nf2 in 1.01f64..100.0,
        g1 in 0.01f64..1e6,
        g2 in 0.01f64..1e6,
    ) {
        // Total noise factor of N stages is NOT order-independent
        // (Friis depends on gain ordering) — this test SHOULD fail
        // for most inputs, which verifies Friis is working correctly.
        let forward = nf1 + (nf2 - 1.0) / g1;
        let reverse = nf2 + (nf1 - 1.0) / g2;
        // Only equal when g1 == g2 AND nf1 == nf2
        if (g1 - g2).abs() > 1e-6 || (nf1 - nf2).abs() > 1e-6 {
            prop_assert!((forward - reverse).abs() > 1e-15,
                "Friis cascade should be order-dependent");
        }
    }
}

Bounds Checking

Verify outputs stay within physical constraints:

proptest! {
    #[test]
    fn noise_figure_at_least_one(
        blocks in prop::collection::vec(rf_block_strategy(), 1..10)
    ) {
        let total_nf = cascade_noise_figure(&blocks);
        prop_assert!(total_nf >= 1.0,
            "Cascade NF must be >= 1 (0 dB), got {}", total_nf);
    }
}

Configuration

Control the number of test cases:

// Per-test
proptest! {
    #![proptest_config(ProptestConfig::with_cases(10_000))]

    #[test]
    fn thorough_roundtrip(db in -300.0f64..300.0) {
        // Runs 10,000 cases instead of the default 256
        let linear = 10.0_f64.powf(db / 10.0);
        let back = 10.0 * linear.log10();
        prop_assert!((back - db).abs() < 1e-8);
    }
}

Or set a global default via environment variable:

PROPTEST_CASES=1000 cargo test

Regression Files

When proptest finds a failure, it saves the failing input to a proptest-regressions/ file next to your test. On subsequent runs, it replays these regressions first — acting as a persistent test case.

Commit proptest-regressions/ to your repo. These are free, targeted test cases discovered by random exploration.

When to Use What

ApproachBest For
Unit testsKnown examples, edge cases, documentation
Property testsInvariants, roundtrips, bounds, "for all X, Y holds"
Mutation testingVerifying test suite quality
Fuzz testingSecurity, crash-finding, untrusted input

Property tests complement unit tests — they don't replace them. Use unit tests for specific known values (0 dB = 1 linear) and property tests for general laws (roundtrip, monotonicity, bounds).

Further Reading