Mocking a GenServer.call reply in a test

Given:

  • GenServer A is a ‘manager’
  • GenServer B is a worker managed by A
  • At some point, B makes a GenServer.call() to A

In a test, what’s the cleanest way to mock A’s handling of B’s GenServer.call() to A?

I’m asking because it feels cleaner to test B’s functionality in isolation.

So far what I’ve come up with is, given B’s GenServer.call() looks like this:

:ok = GenServer.call(state.manager_pid, {:create_success, state})

I can mock A’s receipt and reply like this in my test:

receive do
  {:"$gen_call", {^pid, tag}, {:create_success, _state}} ->
    GenServer.reply({pid, tag}, :ok)
after
  1000 ->
    raise "timeout waiting for :create_success call"
end

Is there a better way to handle it?

Did You have a look at?

You can also use assert_receive to test message passing.

1 Like

Excellent article, thanks for pointing it out. Turns out I’m already structuring most things the way the article suggests.

That seems handy, but in this case it doesn’t account for having to send a reply to B’s GenServer.call.

I’m starting to think it might be cleaner to build a simple Testing GenServer that could receive these GenServer.call’s from other servers, store the passed term(s), and provide access to retrieving them to inspect for validity.

If they’re closely tied, wouldn’t it make sense to just test them together? Otherwise your tests will break as soon as the worker <-> manager interface changes, even if the change is perfectly correct.

1 Like

I thought about that, but the manager server does a bunch of other complicated stuff, and its interaction with the worker in this case is pretty limited (receiving the data from the GenServer.call, and responding with :ok)

In this scenario it seems best to isolate the worker for testing purposes.

If the response is always ok, could you make it a cast rather than a call?

@kokolegorille I decided to take a crack at a generally useful mock GenServer, code below. It’s not perfect, but seems useful given the time spent :slight_smile:

I’d use it like this:

setup do
  {:ok, manager_pid} = Mock.GenServer.start_link
  %{
    manager_pid: manager_pid,
  }
end

test "create success returns valid results, exits normally", %{manager_pid: manager_pid} do
  {:ok, pid} = create_and_execute_worker(manager_pid)
  ref = Process.monitor(pid)
  receive do
    {:DOWN, ^ref, :process, same_pid, :normal} ->
      assert pid == same_pid
      [response] = Mock.GenServer.get_call_values(manager_pid)
      assert {:create_success, %{result: {:ok, _metadata, _ip, _stats}}} = response
  after
    1000 ->
      raise "timeout"
  end
end

Server code:

defmodule Mock.GenServer do
  @moduledoc """
  Documentation for Mock.GenServer.

  Can be used to mock communication to/from a GenServer. Stores all data
  passed to call/cast/info for later examination.

  Responses to GenServer.call can be configured by mapping the call 'action'
  to the desired response. The action is derived as follows.
    - If data is an atom, the action is the atom.
    - If data is a tuple, the action is the first element of the tuple.
  e.g, if another server issues GenServer.call({:create, data}), the action
  would be :create.

  The response map is passed when creating the server, keys are actions,
  values are the desired response for the action.

  Caveats:
    - GenServer.call data cannot exactly match any of the internal handle_call
      patterns (:call_values, :cast_values, etc).
    - An action response can only be derived if the passed data is an atom or
      a tuple. This seems reasonable given standard GenServer usage. If no
      action can be determined from the response map, a default response is
      returned.
  """

  use GenServer
  require Logger

  @logger_metadata [name: :mock_genserver]
  @default_response :ok

  ## API

  def start_link(response_map \\ %{}) do
    Logger.debug fn -> {"Starting mock GenServer instance, with response map: " <> inspect(response_map), @logger_metadata} end
    GenServer.start_link(__MODULE__, response_map)
  end

  def get_call_values(pid) do
    GenServer.call(pid, :call_values)
  end

  def get_cast_values(pid) do
    GenServer.call(pid, :cast_values)
  end

  def get_info_values(pid) do
    GenServer.call(pid, :info_values)
  end

  def update_response(pid, action, response) do
    GenServer.call(pid, {:update_response, action, response})
  end

  ## Callbacks

  def init(response_map) do
    Logger.debug fn -> {"Initializing mock GenServer #{inspect(self())} instance with response map: " <> inspect(response_map), @logger_metadata} end
    state = %{
      response_map: response_map,
      call_values: [],
      cast_values: [],
      info_values: [],
    }
    {:ok, state}
  end

  def handle_call(:call_values, _from, state) do
    {:reply, state.call_values, state}
  end

  def handle_call(:cast_values, _from, state) do
    {:reply, state.cast_values, state}
  end

  def handle_call(:info_values, _from, state) do
    {:reply, state.info_values, state}
  end

  def handle_call({:update_response, action, response}, _from, state) do
    state = put_in(state[:response_map][action], response)
    {:reply, state.response_map, state}
  end

  def handle_call(term, _from, state) do
    response = Map.get(state.response_map, get_action(term)) || @default_response
    {:reply, response, Map.put(state, :call_values, state.call_values ++ [term])}
  end

  def handle_cast(term, state) do
    {:noreply, Map.put(state, :cast_values, state.cast_values ++ [term])}
  end

  def handle_info(term, state) do
    {:noreply, Map.put(state, :info_values, state.info_values ++ [term])}
  end

  def terminate(reason, state) do
    Logger.debug fn -> {"Terminating mock GenServer #{inspect(self())} instance, reason: #{inspect(reason)}, state: #{inspect(state)}", @logger_metadata} end
    :ok
  end

  defp get_action(term) do
    if is_atom(term), do: term, else: elem(term, 0)
  end
end

Maybe, but I’m also stopping the worker after sending this info to the manager, and the manager monitors the worker (to verify if it crashed so it could be restarted).

It seems I might enter into a race condition by using cast, where the manager gets the :DOWN message before the success data. I figure as long as I can ensure that I’ve received the data successfully first, I can ignore the :DOWN message from the worker exiting. Perhaps I’m showing my ignorance there about how inter-process messaging works :slight_smile:

That’s safe, message ordering between two processes is guaranteed in Erlang, and this includes monitor signals.

See: http://erlang.org/pipermail/erlang-questions/2013-January/072098.html

Is there no risk in the case of a cast that the message was never received by the callee? In case of a call you know for sure that the callee has received the message.

That’s correct, but why would the worker care if the message actually made it? It’s just going to stop either way.

Cool, thanks for the clarification. I’ve decided to stick with the GenServer.call() for now, since I have it working.

The little mock GenServer I posted above is also performing admirably in my test suite :slight_smile:

That thread focuses on an explicit exit signal triggered by a process right after it sends a message to the process that is linked to it. I’ve yet to come across a reference that specifies that a :DOWN message originates from the terminating process - so I’ve always assumed that it comes from the BEAM - which would make it a third party in terms of message ordering.

Practically though the terminating message is sent before the process actually terminates, so it would make sense that the :DOWN message follows the terminating message but:

  • Check the reason of the :DOWN message - that should indicate whether there should be a termination message.
  • When receiving the termination message, use Process.demonitor(ref,[:flush]) to purge any potential following :DOWN message.

Even this statement isn’t clear as no differentiation between :EXIT and :DOWN is being made. It seems that the intended order is:

  1. termination message
  2. :EXIT message
  3. :DOWN message

but there seems to be a remarkable lack of clarity around the issue.

1 Like

On that topic, a timely PR just landed :slight_smile:

1 Like

And this is part of the reason why I feel better just leaving my synchronous call in place, it guarantees the order I want!