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
endNow 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
endNow 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
endOk, 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!