View Source Chapter 5: Processes
Elixir code frequently runs many processes and a test author often wants to assert about the flow of messages between processes. Patch
provides some utilities that make listening to the messages between processes easy.
listeners
Listeners
Listeners are processes that sit between the sender process and the target process. The listener process will send a copy of every message to the test process so it can use ExUnit's built in assert_receive
, assert_received
, refute_receive
, and refute_received
functions.
Listeners are especially useful when working with named processes since they will automatically unregister the named process and take its place. For anonymous processes the inject/3
function is provided to assist in injecting listeners into other processes or the listener can be used in place of the target process when starting consumer processes.
Listeners are started with the listen/3
function and each have a tag
so that the test process can differentiate which listener has delivered which message.
defmodule PatchExample do
use ExUnit.Case
use Patch
test "sharded read replication" do
listen(:shard_a_leader, ShardALeader)
listen(:shard_a_replica_1, ShardAReplica1)
listen(:shard_a_replica_2, ShardAReplica2)
listen(:shard_b_leader, ShardBLeader)
listen(:shard_b_replica_1, ShardBReplica1)
listen(:shard_b_replica_2, ShardBReplica2)
send(ShardALeader, {:write, :some_value})
# Assert the leader gets the message
assert_receive {:shard_a_leader, {:write, :some_value}}
# Assert that the replicas for Shard A get the message too
assert_receive {:shard_a_replica_1, {:write, :some_value}}
assert_receive {:shard_a_replica_2, {:write, :some_value}}
# Assert that Shard A does not try to replicate to Shard B
refute_receive {:shard_b_leader, {:write, :some_value}}
refute_receive {:shard_b_replica_1, {:write, :some_value}}
refute_receive {:shard_b_replica_2, {:write, :some_value}}
end
end
genserver-support
GenServer Support
Listeners have special support for GenServers. By default a listener will provide the test process with all calls, replies, casts, and messages.
Given a listener with the tag :tag
the messages from a GenServer are formatted as follows.
Client Code | Message to Test Process |
---|---|
GenServer.call(pid, :message) | {:tag, {GenServer, :call, :message, from}} |
# if capture_replies = true | {:tag, {GenServer, :reply, result, from}} |
GenServer.cast(pid, :message) | {:tag, {GenServer, :cast, :message}} |
During a GenServer.call/3
the listener sits between the client and the server and reports back information to the test process.
.------------. .------. .--------. .------.
|Test Process| |client| |listener| |server|
'------------' '------' '--------' '------'
| | GenServer.call(message)| |
| | -----------------------> |
| | | |
| {GenServer, :call, message, from} | |
| <- - - - - - - - - - - - - - - - - - - - - - |
| | | |
| | | GenServer.call(message)|
| | | ----------------------->
| | | |
| | | reply |
| | | <-----------------------
| | | |
| {GenServer, :reply, reply, from} | |
| <- - - - - - - - - - - - - - - - - - - - - - |
| | | |
| | reply | |
| | <----------------------- |
.------------. .------. .--------. .------.
|Test Process| |client| |listener| |server|
'------------' '------' '--------' '------'`
GenServer.call/3
allows the client to set a timeout, an amount of time to wait for the server to response. The listener does not know how long the original client will wait for a timeout, the test author can provide a :timeout
option when spawning the listener to control how long the listener will wait for its GenServer.call/3
. By default the listener will wait 5000ms for each call, the default for GenServer.call/2
.
Since assert_receive/3
supports binding, we can use the from
to match a call and a reply.
defmodule PatchExample do
use ExUnit.Case
use Patch
test "matching calls and replies" do
Counter.start_link(0, name: Counter)
listen(:counter, Counter)
assert Counter.increment() == 1
assert_receive {:counter, {GenServer, :call, :increment, from}} # Bind `from`
assert_receive {:counter, {GenServer, :reply, 1, ^from}} # Match the pinned `from`
end
end
If the test doesn't require the listener to capture replies to GenServer.call
then the :capture_replies
option can be set to false. When this option is false the listener will simply forward the call onto the server. Refer to the following diagram for details on how this works.
.------------. .------. .--------. .------.
|Test Process| |client| |listener| |server|
'------------' '------' '--------' '------'
| | GenServer.call(message)| |
| | -----------------------> |
| | | |
| {GenServer, :call, message, from} | |
| <- - - - - - - - - - - - - - - - - - - - - - |
| | | |
| | | send(:"$gen_call", from, message)|
| | | --------------------------------->
| | | |
| | | reply |
| | <----------------------------------------------------------
.------------. .------. .--------. .------.
|Test Process| |client| |listener| |server|
'------------' '------' '--------' '------'
target-monitoring
Target Monitoring
Listeners will automatically monitor the target process they are listening to. If the target process goes :DOWN
the listener will deliver a tagged {:DOWN, reason}
message to the test process and then exit.
injecting-listeners
Injecting Listeners
listen/3
works well for named processes when callers are using the name to send messages to the target process. What should we do when callers are sending to a pid instead of a name? This is where the inject/4
function can be used.
inject/4
will extract a pid out of a GenServer's state, wrap it with a listener and then replace the pid in the GenServer's state with the listener pid. inject/4
will also work with keys in the GenServer's state that are nil
, it will generate a targetless listener and replace the nil
with that listner's pid. This latter behavior is nice when working with a process that will spawn another process and keep track of its pid in state if the spawning behavior is outside the scope of your test.
Here's a simple example, we will have 2 modules, Target
and Caller
.
defmodule Target do
use GenServer
## Client
def start_link(multiplier) do
GenServer.start_link(__MODULE__, multiplier)
end
def work(pid, argument) do
GenServer.call(pid, {:work, argument})
end
## Server
def init(multiplier) do
{:ok, multiplier}
end
def handle_call({:work, argument}, _from, multiplier) do
{:reply, argument * multiplier, multiplier}
end
end
Our Target
module isn't very interesting, it can do some work/1
where the caller sends it a number and it multiplies it by the multiplier
it was started with and returns it.
Next let's look at our Caller
defmodule Caller do
use GenServer
defstruct [:bonus, :target_pid]
## Client
def start_link(bonus, multiplier) do
GenServer.start_link(__MODULE__, {bonus, multiplier})
end
def calculate(pid, argument) do
GenServer.call(pid, {:calculate, argument})
end
## Server
def init({bonus, multiplier}) do
{:ok, target_pid} = Target.start_link(multiplier)
{:ok, %__MODULE__{bonus: bonus, target_pid: target_pid}}
end
def handle_call({:calculate, argument}, _from, %__MODULE__{} = state) do
multiplied = Target.work(state.target_pid, argument)
{:reply, multiplied + state.bonus, state}
end
end
Our Caller
takes two values, a bonus
and a multiplier
. It spawns a new Target
process with the multiplier and stores the target_pid
in its state.
The Caller
process will send a message to the target_pid
it has stored in its state.
Here's how we can use inject/4
to listen to the messages between the Caller
process and the Target
process.
defmodule PatchExample do
use ExUnit.Case
use Patch
test "listen to messages to Target Process" do
bonus = 5
multiplier = 10
{:ok, caller_pid} = Caller.start_link(bonus, multiplier)
inject(:target, caller_pid, [:target_pid])
assert Caller.calculate(caller_pid, 7) == 75 # (7 * 10) + 5
assert_receive {:target, {GenServer, :call, {:work, 7}, from}}
assert_receive {:target, {GenServer, :reply, 70, ^from}}
end
end
inject/4
accepts the same options as listen/3
and returns the {:ok, listener_pid}
after successfully injecting the listener.
targetless-listeners
Targetless Listeners
Listeners can also be used in place of a real process. Targetless listeners are nearly identical to listeners with one key exception, GenServer.call/3
. A Targetless Listener will happily forward a normal message sent with send/3
or a GenServer.cast/2
message to nowhere, but GenServer.call/3
expects a reply and there isn't one coming when the Listener has no Target.
To make this failure mode more apparent, since it likely means that the author didn't intend to set up a Targetless Listener, attempting to GenServer.call/3
at a Targetless Listener will have the following effects.
- The listener will forward
{tag, {GenServer, :call, message, from}}
to the test process - The listener will forward
{tag, {:EXIT, :no_listener_target}}
to the test process - The listener will crash with the reason
:no_listener_target
replacing-state
Replacing State
When working with processes in test code it is sometimes necessary to change the state of a running GenServer. Common use cases for injecting state into a GenServer are to set up some fixture data, update a configuration value, or replacing pids like in the previous section.
replace/3
is a helper that handles some common issues when updating state.
defmodule PatchExample do
use ExUnit.Case
use Patch
test "state can be replaced" do
{:ok, pid} = Target.start_link(:initial_value)
assert :initial_value == Target.get_value(pid)
replace(pid, [:value], :updated_value)
assert :updated_value == Target.get_value(pid)
end
end
replace/3
accepts a GenServer.server
a list of keys
like one would use for put_in/3
and then a value to inject into the processes state.
Unlike put_in/3
, replace/3
will work with Structs that do not implement the Access
behaviour. It does not support the Access functions though, just a list of keys.