Testing Processes
Testing with ExUnit tested plain functions: call, compare, done. A process is harder to test. It has to be started and stopped, it answers with messages that arrive later, it may run on a timer, and a process with a name is shared by every test. This page tests a small GenServer and the application around it:
- A fresh process for each test with
start_supervised!/1. - Checking messages a process sends, without sleeping.
- Driving a timer by sending its message yourself.
- Testing a crash.
- Testing the processes the application starts by name.
The project
quota has one GenServer, Quota.Meter. It counts hits per key, forgets the
counts every interval milliseconds, and tells a notify process when a key
goes over its limit.
defmodule Quota.MixProject do
use Mix.Project
def project do
[
app: :quota,
version: "0.1.0",
elixir: "~> 1.20",
elixirc_options: [warnings_as_errors: true],
deps: []
]
end
def application do
[
mod: {Quota.Application, []},
extra_applications: [:logger]
]
end
end
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
Everything the meter needs comes in as options, including the interval and
the process to notify. That's what makes it testable: a test can pick a short
limit, a reset that never fires on its own, and itself as the process to
notify. Keyword.pop/2 takes the optional name out of the options, and the
meter is registered under it only if there is one. GenServer.server() and
GenServer.on_start() are types GenServer defines for "a pid or a name" and
"what start_link returns".
The notification is sent before hit/2 replies. When hit/2 returns, the
message is already in the notified process's mailbox.
defmodule Quota.Meter do
@moduledoc """
Counts hits per key and forgets them every `interval` milliseconds. When a
key goes over `limit`, sends `{:over_limit, key}` to the `notify` process.
"""
use GenServer
@doc """
Options: `limit` and `interval` (required), `notify` (a pid, optional) and
`name` (optional).
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
{name, opts} = Keyword.pop(opts, :name)
GenServer.start_link(__MODULE__, opts, if(name, do: [name: name], else: []))
end
@doc "Records a hit and returns the key's count since the last reset."
@spec hit(GenServer.server(), term()) :: pos_integer()
def hit(meter, key), do: GenServer.call(meter, {:hit, key})
@doc "The key's count since the last reset."
@spec count(GenServer.server(), term()) :: non_neg_integer()
def count(meter, key), do: GenServer.call(meter, {:count, key})
@impl true
def init(opts) do
state = %{
limit: Keyword.fetch!(opts, :limit),
interval: Keyword.fetch!(opts, :interval),
notify: Keyword.get(opts, :notify),
counts: %{}
}
schedule_reset(state.interval)
{:ok, state}
end
@impl true
def handle_call({:hit, key}, _from, state) do
count = Map.get(state.counts, key, 0) + 1
if count == state.limit + 1 and state.notify, do: send(state.notify, {:over_limit, key})
{:reply, count, put_in(state.counts[key], count)}
end
def handle_call({:count, key}, _from, state) do
{:reply, Map.get(state.counts, key, 0), state}
end
@impl true
def handle_info(:reset, state) do
schedule_reset(state.interval)
{:noreply, %{state | counts: %{}}}
end
defp schedule_reset(interval), do: Process.send_after(self(), :reset, interval)
end
The application starts one meter, registered as Quota.Meter, with a limit of
100 hits per minute.
defmodule Quota.Application do
use Application
@impl true
def start(_type, _args) do
children = [
{Quota.Meter, name: Quota.Meter, limit: 100, interval: :timer.minutes(1)}
]
Supervisor.start_link(children, strategy: :one_for_one, name: Quota.Supervisor)
end
end
A process per test
mix test starts the application before any test runs, so the named meter is
already up. The tests of the meter itself don't use it: each test starts its
own, so no test sees another's counts.
start_supervised!/1 takes a child spec, as a supervisor's children list
does (see Supervisors), and starts the process under a
supervisor that ExUnit runs for each test. It returns the pid, and stops the
process when the test ends, passed or failed, before the next test starts.
A process started with start_link in the test is linked to it and dies when
the test ends too, but asynchronously: it may still be running, holding its
name or its files, when the next test starts.
The supervised process isn't linked to the test, so its crash doesn't fail
the test by itself, and the test supervisor restarts it like any supervisor
would. start_link_supervised!/1 links it as well, for tests where any crash
should fail the test.
These meters have no name, so the module can be async: true: tests running
at the same time each talk to their own meter by pid.
ExUnit.start(exclude: [:broken])
defmodule Quota.MeterTest do
use ExUnit.Case, async: true
alias Quota.Meter
# Each test gets its own meter, with a reset an hour away: it never fires
# during a test unless the test sends `:reset` itself.
setup do
meter = start_supervised!({Meter, limit: 2, interval: :timer.hours(1), notify: self()})
%{meter: meter}
end
test "counts hits per key", %{meter: meter} do
assert Meter.hit(meter, "a") == 1
assert Meter.hit(meter, "a") == 2
assert Meter.count(meter, "b") == 0
end
test "tells the subscriber once a key is over the limit", %{meter: meter} do
Meter.hit(meter, "a")
Meter.hit(meter, "a")
refute_received {:over_limit, _}
Meter.hit(meter, "a")
assert_received {:over_limit, "a"}
Meter.hit(meter, "a")
refute_received {:over_limit, _}
end
test "forgets every count on reset", %{meter: meter} do
Meter.hit(meter, "a")
send(meter, :reset)
assert Meter.count(meter, "a") == 0
end
@tag :capture_log
test "a bad call crashes the meter and its caller", %{meter: meter} do
ref = Process.monitor(meter)
assert {{:function_clause, _stack}, _call} = catch_exit(GenServer.call(meter, :oops))
assert_receive {:DOWN, ^ref, :process, ^meter, {:function_clause, _stack}}
end
end
Messages, in order of the tests:
- Already sent. The meter notifies before it replies, so after
hit/2returns, the message is either in the test's mailbox or was never sent.assert_receivedchecks the mailbox now, without waiting, andrefute_receivedchecks that no matching message is there.assert_receivedremoves the message it matches, so the lastrefute_receivedproves the notification came only once. - The timer. The reset is an ordinary
:resetmessage the meter sends itself withProcess.send_after/3. The test sends:resetitself instead of waiting a minute. Messages from one process to another arrive in the order they were sent, so the meter handles:resetbefore thecount/2call that follows it. - Arriving later. A process's
:DOWNmessage comes from the runtime when the process dies, at some point after the call fails.assert_receivewaits for a matching message, 100 ms by default (a second argument changes it), and fails if none comes.^refand^meterpin the values already bound, so only this meter's:DOWNmatches.
The crash test calls the meter with a message it has no clause for, so
handle_call/3 raises and the meter dies. A process that dies makes its
callers' GenServer.call/3 exit too, and catch_exit/1 turns that exit into
a value the test can match. The reason is the raw Erlang error,
{:function_clause, stacktrace}, not an exception struct. The dying GenServer
logs an error report, with pids in it. @tag :capture_log captures the logs
of that one test and prints them only if it fails.
The named process
A test of the meter the application started has to share it with every
other test that might touch Quota.Meter. This module is async: false, and
its setup resets the meter first, so earlier hits don't leak in.
The second test shows the other half of the problem: a meter started by a
test can't take a name that's already taken. start_supervised/1 (no !)
returns the error instead of raising.
defmodule Quota.ApplicationTest do
# Uses the meter the application started under the name Quota.Meter, which
# every test can reach: not async.
use ExUnit.Case, async: false
setup do
send(Quota.Meter, :reset)
:ok
end
test "the application starts a named meter" do
assert [{Quota.Meter, pid, :worker, [Quota.Meter]}] =
Supervisor.which_children(Quota.Supervisor)
assert Process.whereis(Quota.Meter) == pid
assert Quota.Meter.hit(Quota.Meter, "a") == 1
end
test "a second meter can't take the name" do
spec = {Quota.Meter, name: Quota.Meter, limit: 1, interval: :timer.hours(1)}
assert {:error, {:already_started, pid}} = start_supervised(spec)
assert pid == Process.whereis(Quota.Meter)
end
end
Run them
The same t helper as on Testing with ExUnit: a fixed seed,
one test at a time, and no run-time line.
$ t() { s=0; mix test --seed 0 --max-cases 1 "$@" > test.log 2>&1 || s=$?; grep -v '^Finished in' test.log; return $s; }
$ t
Compiling 2 files (.ex)
Generated quota app
Running ExUnit with seed: 0, max_cases: 1
Excluding tags: [:broken]
......
Result: 6 passed, 1 excluded
A message that never comes
When assert_receive gives up, it shows what is in the mailbox. This test
waits for a notification about the wrong key:
defmodule BrokenTest do
# Wrong on purpose, to show what a missing message looks like. Excluded by
# default.
use ExUnit.Case, async: true
@moduletag :broken
test "waits for a message that never comes" do
meter = start_supervised!({Quota.Meter, limit: 1, interval: :timer.hours(1), notify: self()})
Quota.Meter.hit(meter, "a")
Quota.Meter.hit(meter, "a")
assert_receive {:over_limit, "b"}
end
end
$ t --only broken
Running ExUnit with seed: 0, max_cases: 1
Excluding tags: [:test]
Including tags: [:broken]
1) test waits for a message that never comes (BrokenTest)
test/broken_test.exs:7
Assertion failed, no matching message after 100ms
Showing 1 of 1 message in the mailbox
code: assert_receive {:over_limit, "b"}
mailbox:
pattern: {:over_limit, "b"}
value: {:over_limit, "a"}
stacktrace:
test/broken_test.exs:11: (test)
Result: 0/1 passed, 6 excluded
Failed: 1 test
When the message did arrive but with a different shape than the pattern, the listing shows the difference at once.
Pitfall: a test that
Process.sleep/1s until something has happened is slow and still fails sometimes on a busy machine. Wait for a message withassert_receive, or make a call to the process: it handles the call after every message the caller sent it before, so the reply means those have all been handled.
Pitfall:
refute_receivewaits its full timeout (100 ms by default) before passing, on every run. When the message would already be in the mailbox, as with the meter's notification,refute_receivedchecks at once.
Pitfall: a process registered under a fixed name can't be started once per test, and async tests would share it. Make the name an option, as the meter does, and let tests start unnamed processes.