Eavesdropping on GenServers Via Tracing
“How can I verify, during testing, that a GenServer or process received a message?”
I have asked myself that on a number of occasions while working with Elixir, especially when interacting with dependencies outside of my control that generate processes and GenServers.
There are many answers to the question, including but not limited to:
- creating a module that delegates calls to the dependency and using the project’s test config to swap in a mock module
- passing a mock module or GenServer directly to the caller you wish to test
- using Mox for general mocking
- using erlang’s trace/3 on the target GenServer and monitoring its messages
The focus of this post will be on the latter of the bunch - utilizing Erlang’s trace/3
function.
Getting Started
If you’re familiar with creating your own project in Elixir, you can follow along by dropping the code below in a project and running the tests. Or, you can clone and reference the full example project in github.
The Sender and Receiver
Let’s say we’re building a framework that uses a GenServer (hereafter called MySender) to perform various tasks. During the execution of these tasks, MySender will call out to various other GenServers, including one in a dependency named BlackBoxReceiver. While we have some control over MySender, its logic is very complicated and spaghettified, and we have no control over BlackBoxReceiver. When the framework calls MySender, it executes an amount of business logic both before and after calling BlackBoxReceiver. For testing purposes, we need to verify both that a call was made by MySender to BlackBoxReceiver, as well as the contents of call itself.
And what about BlackBoxReceiver? In practice, BlackBoxReceiver is a black box we can’t crack open. However, for the sake of this blog post we’ll use the implementation below:
Having both modules ready to go, we know MySender should call BlackBoxReceiver. In fact, we’re pretty sure it’s all working because executing code by hand in the shell looks to be working, and our unit tests cover a lot of angles. Unfortunately, we still have this nagging feeling that we haven’t covered all the bases. So how can we verify that MySender actually sent that vital message to BlackBoxReceiver?
Tracing the Receiver
What we need is a way to watch BlackBoxReceiver and be notified when a particular type of message is sent to it. That’s where Erlang’s trace/3 comes in. When a process executes trace/3
with a pid and list of flags, it will be notified when particular things happen to the process being traced. This is perfect for our test case, where we want to verify BlackBoxReceiver received a particular message. Let’s start by using trace/3
in a test case.
Now run it via mix test
and you will get something like:
The test passes! Great, we have verified both that MySender sent a message to BlackBoxReceiver, and that the message was received. But… what exactly happened?
Erlang’s trace/3
Let’s start with the first line of interest in the unit test:
To understand what that line means, we need to open a browser tab to erlang’s documentation on trace/3. If you are not familiar with Erlang documentation, I’ll paraphrase the trace/3’s signature description…
erlang:trace(PidPortSpec, How, FlagList) -> integer()
translates into:
- In Elixir, you would make the call with
:erlang.trace(..., ..., ...)
. In Erlang, it’serlang:trace(..., ..., ...)
. PidPortSpec
and its list of possible values means you can pass in a pid or port, or any of a list of atoms representing different categories, such as ‘all’, ‘existing_processes’ and more. In Erlang, atoms are lowercase values likeall
, whereas Elixir uses:all
.How
is a boolean value that determines whether the flags you specify inFlagList
will be used when tracingPidPortSpec
.FlagList
is a list of atom flags that act as a filter for the types of trace messages the calling process will receive, and/or options for the tracing itself->
means the following type of data will be returned by this function call, just like Elixir specs and::
.integer()
is the type of return value meaning:Returns a number indicating the number of processes that matched PidPortSpec. If PidPortSpec is a process identifier, the return value is 1. If PidPortSpec is all or existing, the return value is the number of processes running. If PidPortSpec is new, the return value is 0.
Now to apply that information to the line from our test case:
:erlang.trace
is the callreceiver_pid
is the pid we are tracingtrue
means we want to apply the flags to the trace[:receive]
is saying we are only interested in messages being sent toreceiver_pid
- the return value is a
1
since we are passing a pid in forPidPortSpec
Now that we understand trace/3
’s signature and what our particular call means, let’s look at what it’s actually. We can accomplish that by running iex -S mix run
from the command line within our test project and running commands like I have done below. Note that I’ve added spacing to improve readability:
Leave your iex shell from above open, we’ll need it again shortly. For now, we’ll jump down to iex(5)>
, in which we call IEx.Helpers.flush/0. Flush empties the shell’s message inbox and prints out what it found and returns :ok
. When we first call it, it has nothing to show. But after we made our call to MySender.action/2
, we flushed again. This time, the shell had a message in its inbox:
We received a tuple in the format {:trace, <traced process' pid>, <trace type>, <message>}
. The traced process’ pid was #PID<0.116.0>
, the pid of BlackBoxReceiver. The trace type was :receive
, meaning this is a message received by BlackBoxReceiver. And the message was the one sent to BlackBoxReceiver by #PID<0.118.0>
, MySender. Finally, you see the exact data that was sent - {:store, [a: 1]}
.
Now go back to your iex shell. Instead of flushing the message box, we’re going to handle the messages directly:
First, we have MySender perform another action that will cause it to send a message to BlackBoxReceiver. Next, we write a shorthand version of a receive block receive do msg -> msg end
to grab the top message off our inbox. Finally, we use pattern matching to grab the important pieces of the message, then evaluate them to see what they hold.
Also, I tossed in Process.info/2
, which when called on a process with :messages
will return the list of messages in the process’ inbox.
Now that we understand just what trace/3
is doing, let’s now review what the line in our test case containing assert_receive
is doing.
ExUnit’s assert_receive/3
ExUnit provides a number of possible assertions that can be used in your unit tests. One of those is ExUnit.Assertions.assert_receive/3
, which lets a developer check whether the test process has a particular message sitting in its inbox. assert_receive
takes a pattern and waits as long as timeout
to verify the process has received a message in the pattern’s format.
Let’s review the assert_receive
used in our test:
The above should make more sense now that we know how tracing works and the types of messages it sends to the process performing the tracing. The test is asserting that it received a trace message and asserts a match against BlackBoxReceiver’s pid via ^receiver_pid
. Finally, it validates the payload of MySender’s message to BlackBoxReceiver: {:store, _}
.
Conclusion
Erlang’s tracing capabilities are immense and powerful. This blog post doesn’t even scratch the surface of what’s possible with trace/3
and Erlang’s myriad other tracing features. In fact, it’s more like a faint, short-lived breeze blowing over its skin.
However, it’s more than capable of fulfilling our original need, answering the question, “How can I verify, during testing, that a GenServer or process received a message?”
I highly recommend reading through trace/3
’s documentation and playing with its many options in the iex shell.
Final Code
All of the code necessary to get your feet wet with :erlang.trace/3
can be
found in this elixir project.