PropertyDamage.Model behaviour (PropertyDamage v0.2.0)
View SourceBehaviour for models in stateful property-based testing.
A model ties together all the components needed for testing: which commands can be generated, which projections track state and assertions, and the test lifecycle hooks.
Required Callbacks
commands/0- List of command modules (optionally weighted)command_sequence_projection/0- Projection module used for command generation
Optional Callbacks
assertion_projections/0- Projections that verify invariantsinjectable_events/0- Events that can arrive from Adapter.Injector modulessetup_once/1- Setup that runs once at the start (not during shrinking)setup_each/1- Setup that runs before each execution (including shrink attempts)teardown_each/1- Cleanup after each executionteardown_once/1- Final cleanup after all shrinking completeterminate?/3- Control when command generation should stop
Command Sequence Generation
The framework generates command sequences through this loop:
- State Check: Get current state from
command_sequence_projection/0 - Filter Commands: Evaluate each command's
when:precondition against state - Select Command: Choose from valid commands based on
weight: - Generate Instance: Call the selected command's
with:generator with state - Simulate Execution: Call
simulate/2to predict resulting events - Update State: Apply predicted events to the projection
- Repeat: Go to step 2 until sequence length reached
During execution, real events replace simulated predictions, and assertion projections verify invariants.
command_sequence_projection.init()
→ filter commands by `when:` predicate
→ select command (weighted random)
→ generate command data (module generator + `with:` overrides)
→ simulator.simulate(command, state)
→ synthetic events
→ command_sequence_projection.apply(events)
→ updated state
→ repeat until max_commands or terminate?/3 returns trueExample
defmodule MyTest.OrderModel do
@behaviour PropertyDamage.Model
alias MyTest.Commands.{CreateOrder, ViewOrder, CancelOrder}
alias MyTest.Projections.{ModelState, OrderBalances}
@impl true
def commands, do: [CreateOrder, ViewOrder, CancelOrder]
@impl true
def command_sequence_projection, do: ModelState
# Optional: projections that verify invariants
@impl true
def assertion_projections, do: [OrderBalances]
# Terminate when order is deleted
@impl true
def terminate?(_state, %DeleteOrder{}, _events), do: true
def terminate?(_state, _command, _events), do: false
endCommand Specification
Commands are specified with options controlling weight, preconditions, and parameterization:
def commands do
[
# Simple: just module (weight 1, always enabled, no overrides)
CreateOrder,
# Weighted: {module, weight: n}
{ViewOrder, weight: 2},
# Full options: {module, keyword_list}
{CancelOrder,
weight: 1,
when: fn state -> map_size(state.orders) > 0 end,
with: fn state -> %{order_ref: StreamData.member_of(Map.keys(state.orders))} end}
]
endOptions
:weight- Relative selection frequency (default: 1):when- Precondition function(state -> boolean)(default: always true):with- Override function(state -> map)for command generation (default: %{})
Weights express relative frequency among valid commands. If CreateOrder
has weight 3 and CancelOrder has weight 1, and both pass their when: predicates,
CreateOrder will be selected ~75% of the time.
Simulator
Models can define a Simulator module that predicts expected events for each command.
See PropertyDamage.Model.Simulator for the behaviour definition.
defmodule MySimulator do
@behaviour PropertyDamage.Model.Simulator
@impl true
def simulate(%CreateOrder{name: name}, _state) do
[%OrderCreated{name: name, order_ref: nil}]
end
def simulate(%ViewOrder{order_ref: ref}, state) do
if Map.has_key?(state.orders, ref) do
[%OrderViewed{order_ref: ref}]
else
[%OrderNotFound{order_ref: ref}]
end
end
endThen reference it in the model:
def simulator, do: MySimulatorFor inline implementation, have the model implement both behaviours:
defmodule MyModel do
@behaviour PropertyDamage.Model
@behaviour PropertyDamage.Model.Simulator
def simulator, do: __MODULE__
@impl PropertyDamage.Model.Simulator
def simulate(%CreateOrder{name: name}, _state), do: [%OrderCreated{name: name}]
def simulate(_command, _state), do: []
endThis enables symbolic execution during sequence generation.
Lifecycle Diagram
┌─────────────────────────────────────────────────────────────┐
│ Property Test Run │
│ │
│ setup_once/1 ─────────────────────────────────────────┐ │
│ │ │
│ ┌─ Run 1 ──────────────────────────────────────┐ │ │
│ │ setup_each/1 │ │ │
│ │ [execute commands against SUT] │ │ │
│ │ teardown_each/1 │ │ │
│ └──────────────────────────────────────────────┘ │ │
│ │ │
│ ┌─ Run 2 ──────────────────────────────────────┐ │ │
│ │ setup_each/1 │ │ │
│ │ [execute commands against SUT] │ │ │
│ │ teardown_each/1 │ │ │
│ └──────────────────────────────────────────────┘ │ │
│ ... │ │
│ │ │
│ ┌─ If failure, shrinking ─────────────────────┐ │ │
│ │ ┌─ Shrink attempt ────────────────────┐ │ │ │
│ │ │ setup_each/1 │ │ │ │
│ │ │ [execute shrunk sequence] │ │ │ │
│ │ │ teardown_each/1 │ │ │ │
│ │ └─────────────────────────────────────┘ │ │ │
│ │ ... │ │ │
│ └─────────────────────────────────────────────┘ │ │
│ │ │
│ teardown_once/1 ◀─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘Terminal States
The terminate?/3 callback controls when command generation should stop.
This is more flexible than command-level attributes because the same
command may or may not be terminal depending on the test scenario.
Arguments:
state- The current state after applying events from this commandcommand- The command that just executedevents- The events produced by that command
Examples:
- Terminate on specific command:
def terminate?(_state, %Shutdown{}, _events), do: true - Terminate on state:
def terminate?(state, _, _), do: map_size(state.pending) == 0 - Terminate on event:
def terminate?(_, _, events), do: Enum.any?(events, &is_complete?/1)
If not implemented, the framework runs until max_commands is reached.
Summary
Types
Command specification options.
Command specification - module, {module, weight}, or {module, opts}.
Normalized command specification with weight, module, and resolved spec.
Callbacks
Returns list of assertion projection modules.
Returns the projection module used for command sequence generation.
Returns list of command specifications.
Returns list of event modules that can be injected from outside.
Setup that runs BEFORE EACH execution.
Setup that runs ONCE at the start of the property test.
Returns the module implementing the Simulator behaviour.
Teardown that runs after each execution.
Final teardown after all shrinking complete.
Determines if the test should terminate after the given command/events.
Functions
The model's invariant catalog (DR-026).
Normalize a single command specification.
Normalize command list to {weight, module, spec} format.
The full projection list a model exposes.
Resolve command spec from module, using command_spec/1 or legacy callbacks.
Types
@type command_opts() :: [ weight: pos_integer(), when: (map() -> boolean()), with: (map() -> map()) ]
Command specification options.
:weight- Relative selection frequency (default: 1):when- Precondition function(state -> boolean)(default: always true):with- Override function(state -> map)for command generation (default: %{})
@type command_spec() :: module() | {module(), pos_integer()} | {module(), command_opts()}
Command specification - module, {module, weight}, or {module, opts}.
@type normalized_command() :: {pos_integer(), module(), map()}
Normalized command specification with weight, module, and resolved spec.
The spec is a map containing all configuration for the command, resolved
from command_spec/1 or legacy callbacks.
Callbacks
@callback assertion_projections() :: [module()]
Returns list of assertion projection modules.
These projections verify invariants via use PropertyDamage.Model.Projection.
Their state is updated with each command and event, and assertions are run
according to their @trigger conditions.
Optional - defaults to [] if not implemented.
@callback command_sequence_projection() :: module()
Returns the projection module used for command sequence generation.
This projection's state is passed to:
when:predicates in command specs (preconditions)with:override functions in command specs (generators)simulate/2for predicting expected events
During sequence generation, the simulator predicts events and this projection applies them to update state, enabling valid subsequent command selection.
Example
@impl true
def command_sequence_projection, do: MyApp.OrderStateProjection
@callback commands() :: [command_spec()]
Returns list of command specifications.
Each command can be specified as:
Module- Simple module, weight 1, always enabled{Module, weight}- Module with custom weight{Module, opts}- Module with full options (weight, when, with)
Examples
def commands do
[
CreateOrder, # Always enabled, weight 1
{ViewOrder, weight: 2}, # Always enabled, weight 2
{CancelOrder,
weight: 1,
when: fn s -> map_size(s.orders) > 0 end,
with: fn s -> %{order_ref: StreamData.member_of(Map.keys(s.orders))} end}
]
end
@callback injectable_events() :: [module()]
Returns list of event modules that can be injected from outside.
These events arrive via Adapter.Injector modules (webhooks, callbacks, etc.),
not from command execution. Used for validation to ensure all injectable
events are covered by Adapter.Injector @emits declarations.
Optional - defaults to [] if not implemented.
Setup that runs BEFORE EACH execution.
This runs before every execution including every shrink attempt. Use for resetting state that must be pristine (database, cache, etc.).
Returns
:ok- Setup succeeded{:error, reason}- Setup failed, execution skipped
Setup that runs ONCE at the start of the property test.
This is NOT re-run during shrinking. Use for expensive one-time setup like starting applications or external services.
Returns
:ok- Setup succeeded{:error, reason}- Setup failed, test aborted
@callback simulator() :: module()
Returns the module implementing the Simulator behaviour.
The simulator predicts expected events for each command during sequence generation, enabling symbolic execution.
Example
# Reference an external simulator
def simulator, do: MyApp.OrderSimulator
# Or inline (module implements both Model and Simulator behaviours)
def simulator, do: __MODULE__See PropertyDamage.Model.Simulator for implementing the behaviour.
@callback teardown_each(config :: map()) :: :ok
Teardown that runs after each execution.
This is best-effort cleanup. The framework logs warnings if teardowns raise but does not fail the test.
Returns
Always returns :ok. Handle errors internally.
@callback teardown_once(config :: map()) :: :ok
Final teardown after all shrinking complete.
This is best-effort cleanup. The framework logs warnings if teardowns raise but does not fail the test.
Returns
Always returns :ok. Handle errors internally.
Determines if the test should terminate after the given command/events.
Called after each command execution with the updated state.
Return true to stop generating further commands.
Arguments
state- The current state after applying events from this commandcommand- The command that just executedevents- The events produced by that command
Examples
# Terminate on specific command type
def terminate?(_state, %Shutdown{}, _events), do: true
def terminate?(_state, _command, _events), do: false
# Terminate based on state
def terminate?(state, _command, _events) do
map_size(state.pending_payments) == 0
end
# Terminate based on events
def terminate?(_state, _command, events) do
Enum.any?(events, &match?(%PaymentCompleted{}, &1))
end
Functions
@spec assertion_catalog(module()) :: [ %{ projection: module(), id: atom(), invariant: PropertyDamage.Invariants.Invariant.t(), checks: [%{name: atom(), kind: :synchronous | :lifecycle | :polling}] } ]
The model's invariant catalog (DR-026).
The union of every projection's invariant registry (__invariants__/0), keyed
by {projection, id} so two projections may reuse an id for distinct
invariants. Each entry carries the %PropertyDamage.Invariants.Invariant{} and
the assertions that check it, with a per-check kind:
:synchronous- a during-run@trigger every:check:lifecycle- a@trigger at:lifecycle-boundary check:polling- a temporal@poll_statecheck
Returns a list deterministically ordered by {inspect(projection), id}.
@spec normalize_command_spec(command_spec()) :: normalized_command()
Normalize a single command specification.
Resolves the command's spec using command_spec/1 if available,
otherwise falls back to legacy callbacks.
@spec normalize_commands([command_spec()]) :: [normalized_command()]
Normalize command list to {weight, module, spec} format.
Handles all input formats:
Module→{weight, Module, spec}using command_spec/1 or legacy callbacks{Module, weight}→{weight, Module, spec}(legacy format){Module, opts}→{weight, Module, spec}opts passed to command_spec/1%{command: Module, ...}→{weight, Module, spec}map merged with resolved spec
Examples
iex> PropertyDamage.Model.normalize_commands([CreateOrder])
[{1, CreateOrder, %{command: CreateOrder, execution: :sync, ...}}]
iex> PropertyDamage.Model.normalize_commands([{ViewOrder, weight: 2}])
[{2, ViewOrder, %{command: ViewOrder, weight: 2, ...}}]
The full projection list a model exposes.
The command-sequence projection plus any assertion_projections/0,
deduplicated (a projection listed in both appears once).
Resolve command spec from module, using command_spec/1 or legacy callbacks.
Parameters
module- The command moduleopts- Override options to pass to command_spec/1
Returns
A complete spec map.