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.
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.
Add proptest as a dev dependency:
[dev-dependencies]
proptest = "1"
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 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());
}
}
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);
}
}
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.
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);
}
}
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);
}
}
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");
}
}
}
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);
}
}
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
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.
proptest-regressions/ to your repo. These are free, targeted test cases discovered by random exploration.| Approach | Best For |
|---|---|
| Unit tests | Known examples, edge cases, documentation |
| Property tests | Invariants, roundtrips, bounds, "for all X, Y holds" |
| Mutation testing | Verifying test suite quality |
| Fuzz testing | Security, 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).