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:
- Standard controller tests that check one HTTP request. One of the tests will be something like “adding a new animal produces this log message.”
- 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