Testing Singleton Processes with Dependency Injection
When building Elixir systems, we rely on supervision trees. Those supervision trees tend to have singleton processes – unique, global resources in our application.
And typically, we register singleton processes to refer to them by name instead
of using their process IDs (pid
). That makes their usage transparent to the
rest of our system: if the underlying process crashes, a new one can get
restarted under the same name, and the rest of our code is none the wiser.
We can see that in practice, if we launch an iex
session and open up the
Erlang observer (:observer.start()
). In the supervision tree, some processes
have names, and some don’t. The named ones are most likely singletons since we
can only register a single process under a given name.
Named singleton processes are wonderful for our application code, but they often make it hard to test their behavior. Since singletons are started as part of our application’s supervision tree, they become a globally shared resource for our tests. And that can lead to intermittently failing tests due to race conditions.
So, we typically have three options:
- Do not test the singleton process,
- Remove asynchronicity by setting
async: false
, or - Separate the behavior we’re trying to test from what makes the process a singleton.
Let’s look at an example of how to do the third option.
A Counter
Suppose we have a Counter
process in our system that acts as a global, unique
counter. We’ll use an Agent to keep the example simple, and we’ll register
the process locally with the current module’s name
(__MODULE__
). Then the rest of our functions can also use __MODULE__
when
dealing with the state.
defmodule Counter do
use Agent
def start_link(opts) do
initial_value = Keyword.get(opts, :initial_value, 0)
Agent.start_link(fn -> initial_value end, name: __MODULE__)
end
def value do
Agent.get(__MODULE__, & &1)
end
def increment do
Agent.update(__MODULE__, &(&1 + 1))
end
end
We can then add our Counter
to our application supervision tree by setting it
in the start/2
function:
def start(_type, _args) do
children = [
Counter
]
opts = [strategy: :one_for_one, name: Sample.Supervisor]
Supervisor.start_link(children, opts)
end
Testing Counter
Since our Counter
is a singleton, the first version of our test could be this:
test "increment/0 increments value by 1" do
current_value = Counter.value()
Counter.increment()
assert Counter.value() == current_value + 1
end
The test passes, we’re happy, and we commit our code. But right now, our test is using the globally shared resource.
What happens when we run all our tests asynchronously, and others tests also increase the counter?
All of a sudden, we start getting intermittent failures because
Counter.value()
sometimes returns unexpected numbers:
1) test increment/0 increments value by 1 (CounterTest)
Assertion with == failed
code: assert Counter.value() == current_value + 1
left: 2
right: 1
stacktrace:
test/counter_test.exs:9: (test)
Finished in 0.06 seconds (0.06s async, 0.00s sync)
1 doctest, 3 tests, 1 failure
Multiple tests are changing the state simultaneously. Let’s try to decouple the counter behavior from what makes it a singleton.
Decouple name registration with dependency injection
When we look at our Counter
module, we can see an implicit dependency – our
counter process depends on the Counter
’s module name for registration –
and that’s what makes it difficult to test.
What if we make __MODULE__
the default name but use
dependency injection to override it? Then, we could test the behavior in
isolation by injecting other names.
Let’s update our start_link/1
function to take a name
option:
def start_link(opts) do
initial_value = Keyword.get(opts, :initial_value, 0)
name = Keyword.get(opts, :name, __MODULE__)
Agent.start_link(fn -> initial_value end, name: name)
end
Now, let’s update all the other functions in our Counter
module to take a
pid
or name
, keeping __MODULE__
as the default:
def value(counter \\ __MODULE__) do
Agent.get(counter, & &1)
end
def increment(counter \\ __MODULE__) do
Agent.update(counter, &(&1 + 1))
end
The rest of our application code does not need to change: we can still call
Counter.increment()
, and it’ll use the correct default. But our tests can now
start a separate counter process registered under a different name
and use
that pid
or name
to test the module’s behavior:
# using the pid
test "increment/0 increments value by 1" do
{:ok, counter_pid} = Counter.start_link(name: :test_counter)
current_value = Counter.value(counter_pid)
Counter.increment(counter_pid)
assert Counter.value(counter_pid) == current_value + 1
end
# using the name
test "increment/0 increments value by 1" do
{:ok, _counter} = Counter.start_link(name: :test_counter)
current_value = Counter.value(:test_counter)
Counter.increment(:test_counter)
assert Counter.value(:test_counter) == current_value + 1
end
Hooray! Now we’re testing the behavior of our Counter
module without having to
worry about globally shared resources. Each test can spawn a different counter
process.
Using unique names
We’re almost done, but there’s still something that bothers me about our
previous example – we arbitrarily set :test_counter
as the name of the
counter in our test.
Over time, our codebase will grow, and we’ll start running out of creative names. Or worse, we’ll accidentally use names that we’ve used elsewhere, and we’ll run into intermittent failures again.
How can we get unique names that aren’t arbitrary?
We could use some string interpolation with a randomly generated value from
System.unique_integer/1
and turn that into an atom, but I think there’s a
more elegant solution.
ExUnit
to the rescue!
By default, ExUnit
has a test
argument that gets passed into a test as part
of the test context. That argument is the name of the test as an atom – in
our case :"test increment/0 increments value by 1"
. We can’t get much more
unique than that.
Let’s use that test name as the name
of our counter:
test "increment/0 increments value by 1", %{test: test_name} do
{:ok, counter} = Counter.start_link(name: test_name)
current_value = Counter.value(counter)
Counter.increment(counter)
assert Counter.value(counter) == current_value + 1
end
mix test
....
Finished in 0.04 seconds (0.04s async, 0.00s sync)
1 doctest, 3 tests, 0 failures
Nothing but green!
By injecting the registration name into our Counter
module, we’ve successfully
isolated the behavior and tested it asynchronously!