Elixir, testing, exventure, supervision tree

Testing with Mock Processes in Elixir

Recently at SmartLogic and in my side projects, I've wanted to test code that interacts with processes. In a recent Elixir Mix episode with Devon Estes, he and the hosts talked about unit testing in Elixir, and testing code that touches other processes. This got me thinking about how I test code that talks to processes, particularly in ExVenture.

My previous approach was to spin up "real" processes inside of the supervision tree, and have my code talking to those real processes. This was fine in local testing as my desktop was quick enough to spin everything up. But on the slower CI servers, it could cause the tests to pass but have a huge amount of warnings due to the process crashing because Ecto closed its connection to the database.

With the new ideas I got from the episode of Elixir Mix, I set out to replace the way ExVenture tested against live processes.

Why?

Before we continue into how mock processes work, let's look at why you might want to use them.

ExVenture contains a virtual world. That world's state is held in a lot of processes (Rooms). You as a player will be communicating with a single process as you act in the world. As you move between rooms, you change which process you're communicating with.

Given that, I want to be able to test code that interacts with a room process without standing up a real room process inside the supervision tree. A real process would load data from the database on start, but the database is in sandbox mode and doesn’t contain the data the test expects, causing the test to fail. With mock processes I can link a process to the test that acts as a real room, but contains the data the test is expecting.

This approach also lets us hook into parts of the system that might be a plain function. In the case of the client example below, the mock does not replace a process. But because this is a functional language, we need to spin up a process in order to save the state we mock out.

Wrapper Module

The first thing we need to build is a very simple client module. Our client module will define a behavior for all of the functions required. We will use a module attribute to load a module we set via configuration. We configure our mock module for tests, and use the real module in other environments.

See below for the full module.

defmodule App.Client do
  @module Application.get_env(:app, :client)[:module]

  @callback trigger(id()) :: :ok

  def trigger(business_id) do
    @module.trigger(business_id)
  end
end

Handling Casts

This mock module implements the behavior described above. For functions that would normally be a cast it simply redirects all arguments back to the test process.

defmodule App.Client.Mock do
  @behaviour App.Client

  @impl true
  def trigger(business_id) do
    send(self(), {:trigger, business_id})
  end
end

You can assert receive on those messages and know that the code you're actually testing is calling this client code properly.

Handling Calls

In order to handle your code calling a process, the mock spins up a fake client process. This process only exists to be a tiny process that holds requests and responses.

The mock itself starts the fake client and stores the PID in the test process dictionary. The fake client is linked against the test process so that when the test process is taken down, the fake client goes with it.

defmodule App.Client.Mock do
  @behaviour App.Client

  alias App.Client.Mock.FakeClient

  @doc false
  def start_mock() do
    {:ok, pid} = FakeClient.start_link()
    Process.put(:client, pid)
  end

  @doc """
  Set the mock response for the client
  """
  def set_search(query, response) do
    start_mock()
    pid = Process.get(:client)
    GenServer.call(pid, {:set, query, {:ok, response}})
  end

  @impl true
  def search(query) do
    pid = Process.get(:client)
    GenServer.call(pid, {:search, search})
  end
end

set_search/2 starts the mock and pushes the query and response to the new process. search/1 looks up that PID and calls for the response. You can see the FakeClient down below.

There is nothing particularly special about this setup. It's simply a set of handle_call/3 functions that update or respond with the local state. handle_call/3 is used for pushing state into the process which will block the test until the process is updated.

Test Code

Let’s look at the end goal that I was aiming for when replacing the test code in ExVenture. This isn't an example from ExVenture directly, as it's too complex to show as a good example. But if you want to look at a real work application, take a look at the fake room module.

This example will be a small module that talks to a remote API, with a cast and call. With the cast we don't care about what comes back, so this is the more simple case. The call requires a live process to talk to that isn't your test process as the code is running already there.

defmodule App.Businesses do
  alias App.Client

  def trigger_job() do
    businesses = ...
    Enum.map(businesses, fn business ->
      Client.trigger(business.id)
    end
  end

  def search(business) do
    Client.search(business.name)
  end
end

And our tests for the module above:

defmodule App.BusinessesTest do
  use ExUnit.Case

  alias App.Businesses
  alias App.Client.Mock, as: Client

  describe "our client" do
    test "sends a trigger" do
      # generate businesses

      Businesses.trigger_job()

      assert_receive {:trigger, ^business_id}
    end

    test "calls the remote api" do
      business = %{name: "Business"}
      Client.set_search(business.name, %{"hiring" => true})

      {:ok, result} = Businesses.search(business)

      assert result["hiring"]
    end
  end
end

Here we're seeing that the businesses module triggers a remote job, and we assert that they happened from a message that the test process received. Our code that calls another process sets up the search and then lets the code call against it to return what was set and what we assert on it.

Conclusion

This basic pattern helped remove a massive amount of errors due to processes being spun up inside the supervision tree during a test run. It is also cleaner and in the end faster because it doesn’t hit the database as often.

I also set about making a few assertion macros that help with testing the messages your test process receives. Check out the socket macros on GitHub.

Please make sure to check out the ExVenture test suite as it utilizes these techniques throughout, when talking to room state and socket code in particular.

Full Code

Config
config :app, :client, module: App.Client.Implementation
Test Config
config :app, :client, module: App.Client.Mock
Client Module
defmodule App.Client do
  @module Application.get_env(:app, :client)[:module]

  @type id() :: integer()
  @type query() :: String.t()
  @type response() :: map()

  @callback trigger_job(id()) :: :ok
  @callback search(query()) :: {:ok, response()} | {:error, response()}

  @doc """
  Something fire and forget
  """
  def trigger(business_id) do
    @module.trigger(business_id)
  end

  @doc """
  Search remote API
  """
  def search(query) do
    @module.publish(query)
  end
end
Implementation
defmodule App.Client.Implementation do
  @behaviour App.Client

  @impl true
  def trigger(business_id) do
    # real trigger
  end

  @impl true
  def search(query) do
    # real search
  end
end
Mock
defmodule App.Client.Mock do
  @behaviour App.Client

  alias App.Client.Mock.FakeClient

  @doc false
  def start_mock() do
    {:ok, pid} = FakeClient.start_link()
    Process.put(:client, pid)
  end

  @impl true
  def trigger(business_id) do
    send(self(), {:trigger, business_id})
  end

  @doc """
  Set the mock response for the client
  """
  def set_search(query, response) do
    start_mock()
    pid = Process.get(:client)
    GenServer.call(pid, {:set, query, {:ok, response}})
  end

  @impl true
  def search(query) do
    start_mock()
    pid = Process.get(:client)
    GenServer.call(pid, {:search, search})
  end
end
FakeClient
defmodule App.Client.Mock.FakeClient do
  @moduledoc false

  use GenServer

  def start_link() do
    GenServer.start_link(__MODULE__, [])
  end

  def init(_) do
    {:ok, %{}}
  end

  def handle_call({:set, query, response}, _from, state) do
    state = Map.put(state, query, response)
    {:reply, :ok, state}
  end

  def handle_call({:search, query}, _from, state) do
    {:reply, Map.get(state, query), state}
  end
end