PropertyDamage.Adapter behaviour (PropertyDamage v0.2.0)
View SourceBehaviour for adapters that execute commands against the System Under Test.
Adapters are the bridge between the test framework and the actual SUT. They translate command structs into real operations (HTTP calls, function calls, message sends, etc.) and return the resulting events.
Lifecycle
During each test run (including shrink attempts), the adapter lifecycle is:
setup/1- Establish connections, create clientsexecute/2× N - Execute each command in the sequenceteardown/1- Cleanup connections
The full lifecycle with model hooks:
Property test run
├── Model.setup_once() # Once at start
│
├── Run 1
│ ├── Model.setup_each() # Reset SUT state
│ ├── Adapter.setup() # Establish connections
│ ├── Adapter.execute() × N # Execute each command
│ └── Adapter.teardown() # Cleanup connections
│
├── Run 2 ... Run N # Same as above
│
├── [On failure] Shrinking
│ ├── Shrink attempt 1
│ │ ├── Model.setup_each()
│ │ ├── Adapter.setup()
│ │ ├── Adapter.execute() × M
│ │ └── Adapter.teardown()
│ └── ...
│
└── Model.teardown_once() # Once at endExample
defmodule MyTest.APIAdapter do
use PropertyDamage.Adapter
@impl true
def setup(config) do
{:ok, client} = HTTPClient.start(base_url: config[:api_url])
{:ok, %{client: client}}
end
@impl true
def teardown(%{client: client}) do
HTTPClient.stop(client)
:ok
end
@impl true
def execute(%CreateOrder{amount: amt}, %{client: client}) do
case HTTPClient.post(client, "/orders", %{amount: amt}) do
{:ok, %{status: 201, body: body}} ->
{:ok, [%OrderCreated{order_id: body["id"], amount: amt}]}
{:ok, %{status: 400}} ->
{:ok, [%OrderRejected{reason: :invalid}]}
{:error, reason} ->
{:error, reason}
end
end
endDelegation
For complex adapters, use delegate_execution/1 to route commands to
sub-adapter modules:
defmodule MyTest.MainAdapter do
use PropertyDamage.Adapter
delegate_execution for: [CreateOrder, ViewOrder], to: OrdersSubAdapter
delegate_execution for: [CreatePayment], to: PaymentsSubAdapter
@impl true
def setup(config), do: {:ok, config}
@impl true
def teardown(_context), do: :ok
endStutter/Idempotency Testing
When stutter testing is enabled, the framework may execute commands multiple
times to verify idempotent behavior. During retry executions, the adapter
context includes a :stutter key with information the adapter can use:
%{
stutter: %{
attempt: 2, # Current attempt (2, 3, etc. for retries)
is_retry: true, # Always true for retry executions
idempotency_key: "abc123" # From Command.idempotency_key/1, or nil
}
}Adapters can use this to include idempotency keys in HTTP headers:
def execute(%CreateOrder{} = cmd, context) do
headers = build_headers(context)
# headers will include "Idempotency-Key" if stutter context present
HTTPClient.post(context.client, "/orders", body, headers)
end
defp build_headers(%{stutter: %{idempotency_key: key}}) when is_binary(key) do
[{"Idempotency-Key", key}]
end
defp build_headers(_context), do: []The first execution (attempt 1) does NOT include stutter context, only retries do. This allows the adapter to behave normally for the initial execution.
Mid-Execution Event Injection
For commands with :async semantics that poll for completion, you may want to
emit events as they happen rather than batching all events at the end. The
adapter context includes an :inject function for this purpose:
%{
inject: #Function<...> # Call with event to inject it immediately
}Use this to emit events at the correct time in the execution timeline:
def execute(%CreateAuthorization{} = cmd, ctx) do
# Step 1: Create the authorization (T=0)
{:ok, %{body: %{"id" => id, "status" => "processing"}}} =
Req.post(ctx.client, url: "/authorizations", json: payload)
# Inject immediately - projections update NOW at T=0
ctx.inject.(%AuthorizationCreated{authorization_id: id})
# Step 2: Poll until settled (T=5000)
case poll_until_settled(ctx.client, id) do
:approved ->
# Return settlement event - recorded at T=5000
{:ok, [%AuthorizationApproved{authorization_id: id}]}
:declined ->
{:ok, [%AuthorizationDeclined{authorization_id: id}]}
end
endKey behaviors:
- Injected events update projections immediately
- Injected events are recorded in the event log with source
:injected - For events with
external()fields, values are captured from the first injected event - Adapters that don't use
injectcontinue to work unchanged
This is particularly useful when your model needs to track intermediate states, or when assertions depend on events appearing at the correct point in time.
Execute Context
The context map passed to execute/2 contains:
| Key | Type | Description |
|---|---|---|
| (from setup) | any | Whatever your setup/1 returned (e.g., :client, :conn) |
:inject | function | Call with event to inject it immediately into projections |
:start_poller | function | Start a background resource poller (see ResourcePoller) |
:stutter | map | Present only during retry executions (stutter/idempotency testing) |
The :stutter map (when present) contains:
| Key | Type | Description |
|---|---|---|
:attempt | integer | Current attempt number (2, 3, etc. for retries) |
:is_retry | boolean | Always true for retry executions |
:idempotency_key | string or nil | From Command.idempotency_key/1 if implemented |
Summary
Types
Full context passed to execute/2, teardown/1, and register_handler/2.
Stutter context for idempotency testing.
Timeout value for command execution.
Context returned by setup/1.
Callbacks
Execute a command against the SUT and return resulting events.
Optional callback for commands that need to register injector handlers.
Called once per run to establish context.
Called once per run after all commands have executed (or on failure).
Return the timeout for executing a command.
Functions
Macro to define an adapter with default behaviors.
Delegates command execution to a sub-adapter module.
Types
@type context() :: %{ :inject => (struct() -> :ok), :start_poller => (keyword() -> PropertyDamage.ResourcePoller.t()), optional(:stutter) => stutter_context(), optional(atom()) => any() }
Full context passed to execute/2, teardown/1, and register_handler/2.
This map contains:
- All keys from your
user_context()returned bysetup/1 :inject- Function to inject events mid-execution (always present):start_poller- Function to start background resource polling (always present):stutter- Stutter context (only present during retry executions)
Example
def execute(%CreateOrder{} = cmd, context) do
# Access your setup context
client = context.client
# Inject events mid-execution (for async commands)
context.inject.(%OrderCreated{id: id})
# Start a background poller for async resources
context.start_poller.(
poll_fn: fn -> check_status(client, id) end,
handler: fn response -> handle_status(response) end,
interval_ms: 500,
timeout_ms: 30_000
)
# Check for stutter/retry context
case context do
%{stutter: %{idempotency_key: key}} when is_binary(key) ->
# Include idempotency header
_ ->
# Normal execution
end
end
@type stutter_context() :: %{ attempt: pos_integer(), is_retry: boolean(), idempotency_key: String.t() | nil }
Stutter context for idempotency testing.
Present in context() only during retry executions when stutter testing is enabled.
@type timeout_value() :: pos_integer() | {pos_integer(), :milliseconds | :seconds | :minutes}
Timeout value for command execution.
- Integer values are interpreted as seconds (e.g.,
30= 30 seconds) - Use tuples for other units:
{500, :milliseconds}- 500ms{2, :seconds}- 2 seconds{5, :minutes}- 5 minutes
@type user_context() :: map()
Context returned by setup/1.
This is a map containing whatever your adapter needs for execution: HTTP clients, database connections, configuration, etc.
Example
# In setup/1:
{:ok, %{client: http_client, base_url: "http://localhost:4000"}}
# In execute/2, pattern match on these keys:
def execute(%CreateOrder{} = cmd, %{client: client, base_url: url}) do
# ...
end
Callbacks
@callback execute(command :: struct(), context()) :: {:ok, [event :: struct()]} | {:error, term()} | {:settled, [event :: struct()]} | {:retry, term()}
Execute a command against the SUT and return resulting events.
This is called once per command in the sequence. The command struct has already had its Refs/Placeholders resolved to concrete values.
The context() contains your user_context() from setup/1 plus
framework-provided keys like :inject and optionally :stutter.
Returns
{:ok, events}- Command succeeded, events to record{:error, reason}- Command failed, execution stops
For :probe/:async (settle) commands only, execute/2 may also return:
{:settled, events}- The eventually-consistent condition is met; treated like{:ok, events}and stops the settle loop.{:retry, reason}- Not settled yet. The framework re-invokesexecute/2per the command'ssettle_config/0until{:settled, _}or the timeout.
The framework owns the retry loop: an adapter returns {:retry, _} to ask to
be called again, it does not sleep/poll inside execute/2. Returning
{:retry, _} from a :sync command is a contract violation and is reported
as {:retry_from_sync_command, _}.
@callback register_handler(command :: struct(), context()) :: {:ok, handler_ref :: term()} | {:error, term()}
Optional callback for commands that need to register injector handlers.
Some commands may need to set up listeners for async responses before execution. This callback allows registering handlers that will receive events from injector adapters.
@callback setup(config :: map()) :: {:ok, user_context()} | {:error, term()}
Called once per run to establish context.
Use for creating HTTP clients, connecting to databases, starting processes.
The returned user_context() is merged with framework-provided keys
(:inject, :stutter) to form the full context() passed to execute/2.
Returns
{:ok, user_context}- Setup succeeded, context passed to subsequent calls{:error, reason}- Setup failed, run aborted
@callback teardown(context()) :: :ok
Called once per run after all commands have executed (or on failure).
Use for closing connections, stopping processes, cleanup. This is best-effort - the framework logs warnings if teardown raises but does not fail the test.
Note: The context passed here is the full context(), not just user_context().
Returns
Always returns :ok. Handle errors internally.
@callback timeout(command :: struct()) :: timeout_value()
Return the timeout for executing a command.
This callback allows adapters to specify how long a command execution should be allowed to run before timing out. This is particularly useful for load testing where hung commands should not cause unbounded pool growth.
Integer values are interpreted as seconds. Use tuples for other units:
30- 30 seconds{500, :milliseconds}- 500ms{2, :minutes}- 2 minutes
Override for specific commands that need longer timeouts (e.g., polling operations).
Examples
# Default for most commands
def timeout(_command), do: 30 # 30 seconds
# Longer timeout for async commands that poll
def timeout(%CreateAuthorization{}), do: 120 # 2 minutes
# Short timeout for in-memory adapters
def timeout(_command), do: {100, :milliseconds}
Functions
Macro to define an adapter with default behaviors.
Options
:default_timeout- Default timeout for command execution (default: 30 seconds). Can be an integer (seconds) or a tuple like{500, :milliseconds}.
Example
defmodule MyHTTPAdapter do
use PropertyDamage.Adapter, default_timeout: 30 # 30 seconds
# Override for slow polling command
def timeout(%CreateAuthorization{}), do: 120
end
defmodule MyInMemoryAdapter do
use PropertyDamage.Adapter, default_timeout: {100, :milliseconds}
# Override for complex calculation
def timeout(%ComplexCalculation{}), do: {500, :milliseconds}
end
Delegates command execution to a sub-adapter module.
Useful for organizing complex adapters by domain or resource.
Options
:for- List of command modules to delegate:to- Module that handles these commands
Example
delegate_execution for: [CreateOrder, ViewOrder], to: OrdersSubAdapter
delegate_execution for: [CreatePayment], to: PaymentsSubAdapterThe target module should implement execute/2 with the same signature.