How to test: a single asynchronous function outside test, N simultaneous synchronous functions in test

This is something of a mocking/stubbing question. I haven’t found examples that show me how to do it. Pointers to example code or writeups will be much appreciated. I’ll produce a writeup if no one else has.

Newbie here. I’m experimenting with a particular approach, so I’m looking mostly for “here’s how to do that thing you want to do” answers, but also interested in “you shouldn’t want to do that; you should want to do this” answers.

There’s an audit log for important user actions. I’m thinking I should put audit statements in controllers, since that’s where the idea of “responsible user” makes sense (the user_id in the session).

I want production auditing statements to be asynchronous (GenServer.cast) out of (a pretended) need for speed.

I want two types of test:

  1. Standard controller tests that check one HTTP request. One of the tests will be something like “adding a new animal produces this log message.”
  2. Some “workflow” tests that string together multiple HTTP requests. Some of the assertions will need to query the audit log.

These tests would be happy to see Audit.log calls be synchronous (GenServer.call). That seems easier than blocking to make sure a cast has finished.

One approach would be to mock detailed calls into the audit log. I don’t like that because the claim being checked is that the right data was logged, not that a particular function was called in a particular way.

My way of thinking about this is that I want Crit.Audit.ToEcto.Server to be the singleton global audit log, declared via something like this:

config :crit, :persistent_audit_log, Crit.Audit.ToEcto.Server

But when it comes to testing, I want each (async: true) test to provide its own logger (via start_supervised!).

I’m comfortable stashing such data into conn with a plug:

  def call(conn, _opts) do
    if conn.assigns[:audit_server] do
      conn
    else
      assign(conn, :audit_server, Crit.Audit.ToEcto.Server)
    end
  end

But I’m having trouble figuring out the right setup to use the first server below in production, but N copies of the second in test.

defmodule Crit.Audit.ToEcto.Server do
  use GenServer
  alias Crit.Audit.ToEcto.Record
  alias Crit.Repo
  alias Crit.Audit.LogApi
  @behaviour LogApi

  @impl LogApi
  def put(%Crit.Audit{} = entry),
    do: GenServer.cast(__MODULE__, {:put, entry})

  def start_link(_),
    do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)

  # Server part

  @impl GenServer
  def init(:ok) do
    {:ok, :no_state}
  end

  @impl GenServer
  def handle_cast({:put, entry}, _no_state) do
    %Record{}
    |> Record.changeset(Map.from_struct(entry))
    |> Repo.insert!
    {:noreply, :no_state}
  end

end
defmodule Crit.Audit.ToMemory.Server do
  use GenServer
  alias Crit.Audit.LogApi
  @behaviour LogApi

  @impl LogApi
  def put(%Crit.Audit{} = entry),
    do: GenServer.call(__MODULE__, {:put, entry})

  def latest(), do: GenServer.call(__MODULE__, :latest)

  def start_link(_),
    do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)

  # Server part

  @impl GenServer
  def init(:ok) do
    {:ok, []}
  end

  @impl GenServer
  def handle_call({:put, entry}, _from, state) do
    {:reply, :ok, [entry | state]}
  end

  @impl GenServer
  def handle_call(:latest, _from, state) do
    {:reply, {:ok, List.first(state)}, state}
  end    

end

Caution: Seat of the pants thinking ahead …

But I’m having trouble figuring out the right setup to use the first server below in production, but N copies of the second in test.

I think your current strategy falls apart right here

def put(%Crit.Audit{} = entry),
  do: GenServer.cast(__MODULE__, {:put, entry})

def start_link(_),
  do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)

because the process name is hardcoded to the module name. To achieve your objectives you would need to switch to something like:

def put(name, %Crit.Audit{} = entry),
  do: GenServer.cast(name, {:put, entry})

def start_link(name),
  do: GenServer.start_link(__MODULE__, :ok, name: name)
  • In production the name would be read from configuration and used by the supervisor responsible for starting the process. name is also what would be copied into the conn structure (rather than Crit.Audit.ToEcto.Server so that it can be retrieved in the controller to cast to the appropriate process)
  • In testing a faux audit process is spun up under a name unique for the test before the test proper and that name is copied into the testing conn - that way each independent test has its own, separate faux audit process.

You may need to do the un-Erlang/Elixir thing and actually have a separate “client module” for the audit client and another (GenServer-based) module for the audit implementation - i.e. put/2 would end up in the client module while start_link/1 ends up in the process module.