id_effect 0.2.0

Effect<A, E, R> (sync + async), context/layers, pipe — interpreter-style, no bundled executor
Documentation
# 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`](https://github.com/proptest-rs/proptest) and [`quickcheck`](https://github.com/BurntSushi/quickcheck). The examples below use `proptest`.

```toml
[dev-dependencies]
proptest = "1"
```

## Testing Pure Effects

```rust
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.

```rust
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:

```rust
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:

```rust
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`:

```rust
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:

```rust
// 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.