commanded / recipes

Commanded recipes
12 stars 1 forks source link

[Recipe] Event handler testing #15

Open slashdotdash opened 4 years ago

slashdotdash commented 4 years ago

How to test event handler side effects.

defmodule CustomerPromotionHandler do
  use Commanded.Event.Handler,
    application: ExampleApp,
    name: __MODULE__

  def handle(%CustomerPromoted{} = event, _metadata) do
    %CustomerPromoted{customer_id: customer_id} = event

    Notification.send_promotion(customer_id)
  end
end

Following the unit testing matrix outlined by Sandi Metz in her talk The Magic Tricks of Testing), to test side affects in an event handler you could use a mock to verify the expected external command is called. You don’t test the actual side effects when unit testing the handler, but you do verify they are called.

unit-testing-matrix

You can either call the event handler’s handle/2 function directly or cause the event to be produced (via command dispatch or appending the event to a stream) and afterwards verify the side-affect was called.

defmodule CustomerPromotionTest do
  use ExUnit.Case

  import Mox

  setup :verify_on_exit!

  test "`send_promotion/1` is called by promotion handler" do
    reply_to = self()

    NotificationMock.expect(:send_promotion, customer_id ->
      send(reply_to, {:send_promotion, customer_id})
      :ok 
    end)

    # Start handler process and dispatch command to produce trigger event
    start_supervised!(CustomerPromotionHandler)    
    :ok = MyApp.dispatch(command)

    assert_receive {:send_promotion, ^expected_customer_id}    

    # Or call handler directly with an event (doesn’t require the handler process to be started)
    :ok = CustomerPromotionHandler.handle(event, metadata)
  end
end

With Mox you must define a behaviour which can then be mocked or stubbed at runtime.

defmodule NotificationBehaviour do
  @callback send_promotion(non_neg_integer()) :: :ok | {:error, any()}
end

It's often useful to define a facade module for the behaviour which can substitute the mock at runtime or compile time. In this example the implementation module (real or mock) is determined at compile time from application config:

defmodule Notification do
  @behaviour NotificationBehaviour
  @implementation Application.get_env(:myapp, __MODULE__, Notification.DefaultImpl)

  defdelegate send_promotion(customer_id), to: @implementation
end

defmodule Notification.DefaultImpl do
  @behaviour NotificationBehaviour

  def send_promotion(customer_id) do
    # Actual implementation goes here ...
  end
end

The mock must be defined in the test support:

# test/support/mocks.ex
Mox.defmock(NotificationMock, for: NotificationBehaviour)

Then configured for the test environment:

# config/test.exs
config :my_app, Notification, NotificationMock

With the above setup the Notification.DefaultImpl module containing the actual implementation will be used in non-test environments and the mock will be used during tests. This allows the side effect call (send_promotion/1) to be tested, without actually causing the side effect. It also allows you to easily test failures by returning a different response from the mock call (e.g. {:error, failed_to_send}).

To test the event handler and its side effects you could use an integration style test and would replace the side effect module mock with the actual implementation. You could use Mox’s stub_with(NotificationMock, Notification.DefaultImpl) which forwards all calls to the mock to the actual implementation. This ensures the contract is valid between the caller and the target module.