PropertyDamage.MockServiceAdapter behaviour (PropertyDamage v0.2.0)
View SourceBehaviour for mock services that simulate third-party APIs.
MockServiceAdapters intercept calls from the SUT to external services and return controlled responses. They can also inject events into the test framework based on those interactions.
The Problem
When testing a system that depends on external services (payment gateways, email providers, etc.), you need to control what those services return. MockServiceAdapter provides this capability:
┌─────────┐ ┌─────────┐ ┌───────────────────┐
│ Test │──────▶│ SUT │──────▶│ MockServiceAdapter│
│Framework│◀──────│ │◀──────│ (controlled) │
└─────────┘ └─────────┘ └───────────────────┘
│ │
│◀─────────── injected events ─────────┘Key Concepts
Stateful Mock Behavior
Mocks maintain state that evolves with the test. Commands can configure mock behavior, and the mock can react to events:
defmodule PaymentGatewayMock do
use PropertyDamage.MockServiceAdapter
@impl true
def init_state, do: %{behavior: :success}
@impl true
def on_command(%ConfigurePayment{behavior: b}, state) do
%{state | behavior: b}
end
@impl true
def handle_request(%{path: "/charge"}, state) do
case state.behavior do
:success -> {:ok, %{status: 200, body: %{id: "txn_123"}}}
:decline -> {:ok, %{status: 402, body: %{error: "declined"}}}
end
end
endEvent Injection
When the SUT calls the mock, you can inject events into the test:
def handle_request(%{path: "/charge", body: body}, state) do
response = %{status: 200, body: %{transaction_id: "txn_123"}}
# These events are applied to projections
events = [%PaymentProcessed{amount: body["amount"]}]
{:ok, response, events}
endProjection Awareness
Mock handlers receive projection state, enabling realistic responses:
def handle_request(%{path: "/balance"}, state) do
balance = get_in(state.projections, [ModelState, :accounts, id, :balance])
{:ok, %{status: 200, body: %{balance: balance}}}
endExample
defmodule MyTest.PaymentMock do
use PropertyDamage.MockServiceAdapter
@emits [PaymentAuthorized, PaymentDeclined]
@impl true
def setup(config) do
{:ok, pid} = MockServer.start_link(port: 4445, handler: __MODULE__)
{:ok, %{server: pid}}
end
@impl true
def teardown(%{server: pid}) do
MockServer.stop(pid)
:ok
end
@impl true
def init_state do
%{behavior: :success, decline_reason: nil}
end
@impl true
def on_command(%ConfigurePayment{behavior: b, reason: r}, state) do
%{state | behavior: b, decline_reason: r}
end
@impl true
def on_command(_other, state), do: state
@impl true
def on_event(_event, state), do: state
@impl true
def handle_request(%{path: "/authorize", body: body}, state) do
case state.behavior do
:success ->
resp = %{status: 200, body: %{auth_code: "AUTH123"}}
events = [%PaymentAuthorized{amount: body["amount"]}]
{:ok, resp, events}
:decline ->
resp = %{status: 402, body: %{error: state.decline_reason}}
events = [%PaymentDeclined{reason: state.decline_reason}]
{:ok, resp, events}
end
end
@impl true
def handle_request(_request, _state) do
{:ok, %{status: 404, body: %{error: "not_found"}}}
end
endConfiguration Commands
Define commands that configure mock behavior. These commands are executed
against the SUT like normal commands, but the on_command/2 callback
allows mock adapters to react and update their behavior:
defmodule ConfigurePayment do
@behaviour PropertyDamage.Command
defstruct [:behavior, :reason]
@impl true
def precondition(_state), do: true
@impl true
def new!(state, overrides \\ %{}) do
StreamData.fixed_map(%{
behavior: StreamData.member_of([:success, :decline]),
reason: StreamData.member_of([nil, "insufficient_funds"])
})
|> StreamData.map(&struct!(__MODULE__, &1))
end
endThe mock adapter's on_command/2 callback receives all commands, allowing
it to react to configuration commands and update its internal state.
Summary
Callbacks
Handle a request from the SUT.
Initialize the mock's internal state.
React to a command being executed.
React to an event being produced.
Called once per run to start the mock service.
Called once per run to stop the mock service.
Callbacks
@callback handle_request(request :: map(), state :: map()) :: {:ok, response :: map()} | {:ok, response :: map(), events :: [struct()]} | {:error, term()}
Handle a request from the SUT.
Called when the SUT makes a request to the mock service. Return a response and optionally events to inject.
Parameters
request- The request from the SUT (format depends on protocol)state- Current mock state including:projectionskey with projection states
Returns
{:ok, response}- Return response, no events{:ok, response, events}- Return response and inject events{:error, term()}- Simulate an error (timeout, network failure, etc.)
@callback init_state() :: map()
Initialize the mock's internal state.
Called at the start of each test run. Return the initial state that will be passed to other callbacks.
React to a command being executed.
Called before each command is executed against the SUT. Use this to update mock behavior based on commands.
Parameters
command- The command struct being executedstate- Current mock state
Returns
Updated mock state.
React to an event being produced.
Called after events are produced (from SUT or mock injection). Use this to update mock state based on system events.
Parameters
event- The event structstate- Current mock state
Returns
Updated mock state.
Called once per run to start the mock service.
The config includes:
:event_queue- PID of the EventQueue for pushing events:registry- PID of the MockServiceRegistry- Any adapter-specific config
Returns
{:ok, context}- Mock started successfully{:error, reason}- Failed to start
@callback teardown(context :: map()) :: :ok
Called once per run to stop the mock service.
Returns
Always returns :ok.