Testing Asynchronous Code in Elixir
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.