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.