Unit Tests in Elixir - Part 2

In part 1 of this series I went over a couple rules that I follow when writing unit tests. Now I’m going to dig in to some of the specifics of how to unit test certain types of behavior that can be a little tricky to do properly. In part 1 I said that unit tests test all functionality within a single process. But then how can we unit test something that talks to another process?

For today let’s work on unit testing the functions in the Persist module in the following code:

defmodule KVStore do
  use GenServer

  def start_link(_) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  def write(key, value) do
    GenServer.cast(__MODULE__, {:write, key, value})
  end

  def read(key) do
    GenServer.call(__MODULE__, {:read, key})
  end

  def handle_cast({:write, key, value}, state) do
    {:noreply, Map.put(state, key, value)}
  end

  def handle_call({:read, key}, _, state) do
    {:reply, Map.get(state, key), state}
  end
end

defmodule Persist do
  def write_all(params) when is_map(params) do
    Enum.each(params, fn {k, v} -> KVStore.write(key, value) end)
  end

  def read_all(keys) when is_list(keys) do
    Enum.map(keys, &KVStore.read/1)
  end
end

Now all of those functions in Persist send messages to a GenServer. How can we unit test them when all their functionality depends on inter-process communication? Well, we get rid of the inter-process communication.

As the code is now in Persist, the only way we could remove the inter-process communication is to have some sort of configurable adapter in KVStore. But that would be weird, and still pretty hard to test (although in some cases that adapter pattern does work well, but not here).

So, we need a seam in which we can inject our dependencies, and in the case of testing, we can use a mock (or test double, which are pretty much the same thing). The way I like to do that is with default arguments, like so:

defmodule Persist do
  def write_all(params, kvstore \\ KVStore) when is_map(params) do
    Enum.each(params, fn {k, v} -> kvstore.write(k, v) end)
  end

  def read_all(keys, kvstore \\ KVStore) when is_list(keys) do
    Enum.map(keys, &kvstore.read/1)
  end
end

Now that code behaves exactly the same as before, but we also have a seam into which we can inject our dependency as an argument to a function. Yes, it’s a little unwieldy having a default argument at the end of every function there. It isn’t as pretty. But, it allows us to properly unit test those functions like so:

defmodule PersistTest do
  use ExUnit.Case, async: true

  defmodule KVStore do
    def write(key, value), do: send(self(), {:write, {key, value}})

    def read(key), do: {:read, key}
  end

  describe "write_all/2" do
    test "sends the correct message to our KV store for each key/value pair" do
      Persist.write_all(%{key: :value, key2: :value2} KVStore)
      assert_receive({:write, {:key, :value}})
      assert_receive({:write, {:key2, :value2}})
    end
  end

  describe "read_all/2" do
    test "returns a list of values for the given keys" do
      assert Persist.read([:key, :key2], KVStore) == [{:read, :key}, {:read, :key2}]
    end
  end
end

Ok, so what’s going on here. First, we can see that we’re injecting a new module as our test double. That test double isn’t actually a GenServer - it just hard codes some behavior for us. Also, we see that in our write/2 function we send a message to self() (which in this case is the process actually running the test), and in our read/1 function we’re returning values. This is because our write/2 function in our actual implementation is a command, and the read/1 function in our actual implementation is a query. These are terms that come from the object oriented world, but they apply here just as well.

When we send a message to a process and we don’t expect a response (so, a cast in GenServer terms), we’re sending a command. What that process does with that message is totally up to it, and what happens based on that command isn’t the responsibility of any other process. It’s implementation is a private concern. That’s why we don’t test that behavior. We only test that the message was sent. And in the case of a GenServer with a nice public API, we can do that by mocking out that API as we’ve done above. We get notice that the function is called when we receive the message that we’ve sent ourselves. This is as far as this unit test should go - verifiying that the correct function was called with the correct arguments.

When we send a message to a process and we do expect a response (so, a call in GenServer terms), we’re sending a query. In the cases of queries, what is important is that something is returned, but not necessarily what is returned. The logic around what gets returned based for a given message should be unit tested in the KVStoreTest module and not here. When you’re testing queries, the only thing you need to verify is that the function is called with the right argument, and we can do that by asserting against the return value of the function we’re mocking.

So, when you’re testing functions that interact with some other process, remember the three important parts:

  • Inject your dependencies as function arguments so you can use a mock/test double
  • Test commands by sending messages to self()
  • Test queries by asserting against return values

Remember those three rules and you should be able to effectively unit test any function that interacts with another process!