PropertyDamage.MockServiceAdapter behaviour (PropertyDamage v0.2.0)

View Source

Behaviour 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
end

Event 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}
end

Projection 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}}}
end

Example

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
end

Configuration 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
end

The 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

handle_request(request, state)

@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 :projections key 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.)

init_state()

@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.

on_command(command, state)

@callback on_command(command :: struct(), state :: map()) :: map()

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 executed
  • state - Current mock state

Returns

Updated mock state.

on_event(event, state)

(optional)
@callback on_event(event :: struct(), state :: map()) :: map()

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 struct
  • state - Current mock state

Returns

Updated mock state.

setup config

@callback setup(config :: map()) :: {:ok, context :: map()} | {:error, term()}

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

teardown(context)

@callback teardown(context :: map()) :: :ok

Called once per run to stop the mock service.

Returns

Always returns :ok.