Property Testing — Invariants over Inputs
Unit tests check specific cases. Property tests check invariants: statements that must be true for any valid input. Effect programs are excellent targets for property testing because their inputs and outputs are well-typed, their schemas define exactly what's valid, and the layer system makes it easy to run thousands of executions cheaply.
Setup
id_effect works with both proptest and quickcheck. The examples below use proptest.
[dev-dependencies]
proptest = "1"
Testing Pure Effects
#![allow(unused)] fn main() { use proptest::prelude::*; proptest! { #[test] fn addition_is_commutative(a: i64, b: i64) { let eff_ab = add(a, b); let eff_ba = add(b, a); let r_ab = run_test_and_unwrap(eff_ab); let r_ba = run_test_and_unwrap(eff_ba); prop_assert_eq!(r_ab, r_ba); } } }
proptest! generates hundreds of (a, b) pairs. Each iteration calls run_test_and_unwrap, which is cheap for pure effects.
Testing Schema Round-Trips
Schemas have a round-trip property: if you serialise a valid value and re-parse it, you get the same value back.
#![allow(unused)] fn main() { proptest! { #[test] fn user_schema_round_trips( name in "[a-zA-Z]{1,50}", age in 0i64..=120, ) { let original = User { name: name.clone(), age, }; // Serialise to Unknown let raw = User::schema().encode(&original); // Re-parse let parsed = User::schema().run(raw); prop_assert!(parsed.is_ok()); prop_assert_eq!(parsed.unwrap(), original); } } }
Round-trip tests catch asymmetries between your serialiser and parser that unit tests often miss.
Testing Error Invariants
Property tests are excellent for verifying that your error handling is consistent:
#![allow(unused)] fn main() { proptest! { #[test] fn withdraw_never_goes_negative( balance in 0u64..=1_000_000, amount in 0u64..=1_000_000, ) { let account = TRef::new(balance); let exit = run_test_and_unwrap(commit(withdraw(&account, amount))); if amount <= balance { // Should succeed and balance should be reduced assert!(matches!(exit, Exit::Success(_))); let new_balance = atomically(account.read_stm()); assert_eq!(new_balance, balance - amount); } else { // Should fail — balance must not go negative assert!(matches!(exit, Exit::Failure(Cause::Fail(InsufficientFunds)))); let new_balance = atomically(account.read_stm()); assert_eq!(new_balance, balance); // unchanged } } } }
Generating Arbitrary Service Environments
For integration-style property tests, generate random state in the fake service:
#![allow(unused)] fn main() { proptest! { #[test] fn get_user_returns_what_was_saved(user in arbitrary_user()) { let db = Arc::new(InMemoryDb::new()); let env = ctx!(DbKey => db.clone() as Arc<dyn Db>); // Save run_test_with_env( save_user(user.clone()), env.clone(), ); // Retrieve let exit = run_test_with_env(get_user(user.id), env); let retrieved = exit.unwrap_success(); prop_assert_eq!(retrieved, user); } } }
Define arbitrary_user() as a proptest Strategy:
#![allow(unused)] fn main() { fn arbitrary_user() -> impl Strategy<Value = User> { ( "[a-zA-Z ]{1,50}", 0i64..=120, any::<u64>().prop_map(UserId::new), ).prop_map(|(name, age, id)| User { id, name, age }) } }
Schema-Driven Generation
When a type has HasSchema, you can derive a generator that always produces valid inputs:
#![allow(unused)] fn main() { // generate_valid::<User>() produces Users that would pass User::schema() let strategy = generate_valid::<User>(); proptest! { #[test] fn valid_users_are_always_accepted(user in generate_valid::<User>()) { let raw = User::schema().encode(&user); prop_assert!(User::schema().run(raw).is_ok()); } } }
This ensures the generator and schema stay in sync: if you tighten a refine constraint, generate_valid starts producing inputs that satisfy the new constraint.
Shrinking
proptest automatically shrinks failing inputs to the smallest example that still fails. Since run_test is fast (no I/O, no real timers), shrinking runs quickly even with hundreds of iterations.
When a property fails, you'll see the minimal failing case:
Test failed. Minimal failing input:
name = ""
age = -1
Reason: must not be empty (path: name)
This is far more actionable than a raw failure trace from a specific hand-chosen test case.