home

Testing Asynchronous Code in Elixir

Sep 25, 2017

Elixir makes it easy to execute code asynchronously, and, when possible, this should be your preferred way to operate. However, a consequence of this is that code can be hard to test. Our assertions are likely to fail since they'll run before the code under test.

We've been using a simple pattern to work around this. While it doesn't work in all cases, it has proven useful.

First, let's pretend we have a process that logs things asynchronously:

defmodule MyApp.Log do
  use GenServer
  
  def start_link() do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  def log(message) do
    GenServer.cast(__MODULE__, {:log, message})
  end

  def handle_cast({:log, message}, state) do
    # insert into db
    {:noreply, state}
  end
end

The problem with the above, as far as testing goes, is that if we want to write a test that asserts that some function logs a specific message, the following probably won't work:

test "failed login attempt logs a message" do
   Auth.login("invalid", "invalid") 
   assert get_log_from_db() == "invalid login with email 'invalid'"
end

The call to get_log_from_db is likely to be executed before the insert happens.

To solve this, our solution has been to inject an fake that can signal our test, via messaging, when things happen. I'll go over how to do that in a second, but the test ends up looking like:

test "failed login attempt logs a message" do
   Auth.login("invalid", "invalid") 
   assert forwarded() == %{:log, "invalid login with email 'invalid'"}
end

# move this in some the base test template so that all tests have access to it.
def forwarded() do
  receive do
     msg -> msg
  after
    100 -> flunk("expected a message that never came")  # time is in milliseconds
  end
end

We can expand the forwarded/0 helper as much as we want. We can make it collect n messages, or build more type-specific helpers like def fowarded_log(expected_message) do.... The point is that our test stays relatively streamlined without having to sleep or poll or any other such nonsense.

To make this work, the first thing we need is a Forwarder that can send messages to the current test process:

defmodule MyApp.Tests.Forwarder do
  use GenServer

  def start_link() do
    GenServer.start_link(__MODULE__, nil)
  end

  # called at the start of each test to set the current process id
  def setup(pid) do
    GenServer.cast(__MODULE__, {:setup, pid})
  end

  # used to send a message to a test
  def send(msg) do
    GenServer.cast(__MODULE__, {:send, msg})
  end

  # store the current pid as our state
  def handle_cast({:setup, pid}, _state) do
    {:noreply, pid}
  end

  # send the message to the test
  def handle_cast({:send, msg}, pid) do
    send(pid, msg)
    {:noreply, pid}
  end
end

And, make it so that each test sets up the forwarder:

# again, this can be done one in a base test template
setup do
  MyApp.Tests.Forwarder.setup(self())
  :ok
end

Next, we introduce a fake logger which will use the forwarder:

defmodule MyApp.Tests.Fake.Log do
  use GenServer
  
  def start_link() do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  def handle_cast({:log, message}, state) do
    MyApp.Tests.Forwarder.send({:log, message})
    {:noreply, state}
  end
end

We need to start both of these before our tests starts. We can do this by changing test_helper.exs:

{:ok, _pid} = MyApp.Tests.Forwarder.start_link()
{:ok, _pid} = MyApp.Tests.Fake.Log.start_link()
ExUnit.start()

Finally, notice that we're still missing one big link. All of our code is logging by calling MyApp.Log.log("some message") which, so far, has nothing to do with any of our fake stuff. To fix this, we'll change our implementation to send the message to our fake logger while testing, and the real one otherwise:

defmodule MyApp.Log do
  use GenServer

  # Some people don't like this kind of code in their source. It's worked well
  # for us, but if you prefer an alternative (like using a Registry or pushing
  # this to the configurations, be my guest).

  if Mix.env == :test do
    @name MyApp.Tests.Fake.Log
  else
    @name MyApp.Log
  end
  
  def start_link() do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  def log(message) do
    GenServer.cast(@name, {:log, message})
  end

  def handle_cast({:log, message}, state) do
    # insert into db
    {:noreply, state}
  end
end

To recap. Our approach relies on switching the process we cast to while testing. The fake implementation uses send(pid, msg) to send messages to our test. This is achieved by going through a Forwarder which is told the pid of the current running test.

All of this is similar to how we can do dependency injection in Elixir, but with less ceremony and some reusable pieces that can be used by any GenServer.