PropertyDamage.Model.Projection behaviour (PropertyDamage v0.2.0)
View SourceBehaviour for projections that track state and optionally define assertions.
Projections are the core building block for stateful property-based testing. They serve two purposes:
- State tracking: Reduce commands and events into state via
apply/2 - Invariant checking: Define assertions via
@triggerand@poll_state
Basic Usage
defmodule MyProjection do
use PropertyDamage.Model.Projection
# Track state
def init, do: %{orders: %{}, total: 0}
def apply(state, %OrderCreated{id: id, amount: amt}) do
state
|> put_in([:orders, id], %{amount: amt})
|> update_in([:total], &(&1 + amt))
end
def apply(state, _), do: state
# Synchronous assertion - runs immediately when event occurs
@trigger every: 1
def assert_total_non_negative(state, _cmd_or_event) do
if state.total < 0, do: PropertyDamage.fail!("total is negative", total: state.total)
end
@trigger every: CreateOrder
def assert_order_tracked(state, %CreateOrder{id: id}) do
unless Map.has_key?(state.orders, id) do
PropertyDamage.fail!("order not tracked", order_id: id)
end
end
endAssertion Types
There are two types of assertions:
Synchronous Assertions (@trigger)
Run immediately when the trigger condition is met. Use for invariants that should hold right after a command/event is processed.
@trigger every: 1
def assert_balance_positive(state, _cmd_or_event) do
if state.balance < 0 do
PropertyDamage.fail!("balance is negative", balance: state.balance)
end
end@trigger also supports an at: timing for one-shot checks at a lifecycle
boundary (at: :startup / at: :teardown); see "Lifecycle-Boundary
Assertions" below. Use at: :teardown for safety properties on the settled
final state.
Temporal Assertions (@poll_state)
Spawn a background poller when a trigger event occurs. The poller periodically checks if a predicate becomes true within a timeout. Use for eventual consistency assertions.
@poll_state after: PaymentInitiated, timeout: 5, interval: {100, :milliseconds}
def payment_confirmed(_state, %PaymentInitiated{id: id}) do
fn s -> s.payments[id] == :confirmed end
endDefining Assertions
Assertions are functions that take two arguments:
state- The current projection statecommand_or_event- The command or event that triggered the assertion
Each assertion must be preceded by either a @trigger or @poll_state
attribute. Both @trigger and @poll_state functions can have any name.
The assert_ prefix is optional and conventional but not required.
If a synchronous assertion fails, raise an exception (or use PropertyDamage.fail!/2).
If it returns without raising, the assertion passed.
For @poll_state assertions, the function must return a predicate function
(state -> boolean) that will be polled.
Raising in apply/2
You can raise exceptions in apply/2 to catch transition invariants:
def apply(state, %Withdraw{amount: amt}) do
new_balance = state.balance - amt
if new_balance < 0 do
raise %InsufficientFunds{balance: state.balance, requested: amt}
end
%{state | balance: new_balance}
end@trigger Syntax
Use @trigger with every: to specify when a synchronous assertion runs:
| Syntax | Runs when... |
|---|---|
@trigger every: 1 | After every step |
@trigger every: :command | After any command |
@trigger every: :event | After any event |
@trigger every: CreateOrder | After CreateOrder command/event |
@trigger every: [Cmd1, Cmd2] | After any listed command/event |
@trigger every: 10 | Every 10th step (sampling) |
@trigger every: {5, :command} | Every 5th command |
@trigger every: {3, CreateOrder} | Every 3rd CreateOrder |
A step is each unit processed in the execution stream: every command AND
every event increments the step counter. So every: 1 runs after each command
and after each of its events, while every: {5, :command} counts commands only.
The count in {N, target} must be a positive integer.
Lifecycle-Boundary Assertions (@trigger at:)
@trigger has a second, orthogonal timing axis: at:. Where every: samples
an assertion during the command loop, at: fires it exactly once at a
lifecycle phase boundary. An assertion carries exactly one timing: every: xor
at: (declaring both is a compile error).
| Syntax | Runs... |
|---|---|
@trigger at: :startup | once on the initial init/0 state, after setup/1, before command 1 |
@trigger at: :teardown | once on the fully-settled final state, after all pollers finalize, before teardown/1 |
Because no command or event triggers a lifecycle-boundary assertion, the
second argument is the phase atom (:startup or :teardown); a state-only
check ignores it:
@trigger at: :teardown
def assert_balance_reconciles(state, _phase) do
if state.debits != state.credits do
PropertyDamage.fail!("ledger did not reconcile", state: state)
end
endat: :teardown is the natural home for a safety property ("this never
happens too much"), the temporal dual of @poll_state's liveness ("this
eventually happens"). "Settled" means after both the state pollers
(@poll_state) and the resource pollers have finalized: the one point in a run
where no poller is live and every observed event has been folded into
projection state. A @poll_state liveness timeout preempts the :teardown
checkpoint (a timeout is itself a not-settled outcome). A failing :startup
check halts the run before the first command.
The accumulator contract
A :teardown check runs on the final folded state, so detection depends on
the projection retaining evidence of a violation. Write a safety projection
to accumulate (track a maximum observed value, a sticky violated? flag, an
application count) rather than snapshot the latest value. A snapshot projection
that heals back to a legal value before settling silently misses a transient
over-application:
# GOOD — accumulates: the overshoot leaves a permanent trace.
def apply(%{count: c, max: m} = s, %Applied{}), do: %{s | count: c + 1, max: max(m, c + 1)}
@trigger at: :teardown
def assert_at_most_once(state, _phase) do
if state.max > 1, do: PropertyDamage.fail!("applied more than once", max: state.max)
end
# BAD — snapshots: a 0 -> 2 -> 1 transient is invisible at settle.
def apply(%{count: c} = s, %Applied{}), do: %{s | count: c + 1}
def apply(%{count: c} = s, %Reverted{}), do: %{s | count: c - 1}@poll_state Syntax
Use @poll_state with the following options:
| Option | Type | Description |
|---|---|---|
after: | module or [modules] | Event(s) that spawn the poller |
timeout: | integer or {int, unit} | Max time to poll (integer = seconds) |
interval: | integer or {int, unit} | Polling frequency (integer = seconds) |
Time units (singular or plural): :millisecond(s), :second(s), :minute(s)
Example:
@poll_state after: PaymentInitiated, timeout: 5, interval: {100, :milliseconds}
def payment_confirmed(_state, %PaymentInitiated{id: id}) do
fn s -> s.payments[id] == :confirmed end
endSimplified Usage (No State)
For assertions that only inspect commands/events, skip init/0 and apply/2:
defmodule CommandValidator do
use PropertyDamage.Model.Projection
@trigger every: CreateOrder
def assert_order_has_items(_state, %CreateOrder{items: items}) do
if Enum.empty?(items), do: PropertyDamage.fail!("order must have items")
end
endModel Configuration
In your Model, specify projections:
def command_sequence_projection, do: MyStateProjection # required
def assertion_projections, do: [Validator, Audit] # optionalAll projections (state + extra) use the same Projection behaviour.
Summary
Functions
Execute an assertion.
Check if an event matches a polling trigger.
Check if an assertion should run given the current step context.
Callbacks
Apply a command or event to the state.
Called for each command and event in the execution stream. Can raise an exception to signal a transition invariant violation. Default returns the state unchanged.
@callback init() :: any()
Initialize the projection state.
Called once at the start of each test run. Default returns %{}.
Functions
Execute an assertion.
Assertions are functions decorated with @trigger or @poll_state.
Called when the assertion's trigger condition is met. The assert_ prefix is
conventional but not required; when present it is stripped from the assertion's
reported :name (so def assert_total_ok is reported as :total_ok) while the
full function name is kept internally for dispatch. Exactly one @trigger or
@poll_state may decorate an assertion, never both and never more than one.
Should raise an exception if the assertion fails.
If the function returns without raising, the assertion passed.
Example
@trigger every: 1
def assert_total_non_negative(state, _cmd_or_event) do
if state.total < 0, do: PropertyDamage.fail!("total is negative")
endParameters
state- Current projection statecommand_or_event- The command or event that triggered this assertion
Check if an event matches a polling trigger.
Used by the executor to determine if a @poll_state assertion should spawn
a poller when an event is processed.
Parameters
poll_state- Normalized poll_state spec from assertion metadataevent_module- The module of the event being processed
Returns
true if the poller should be spawned, false otherwise.
Check if an assertion should run given the current step context.
Parameters
trigger- Normalized trigger from assertion metadatastep_type-:commandor:eventmodule- The command or event modulecounters- Map with:step,:command,:event, and per-module counts
Returns
true if the assertion should run, false otherwise.