Testing Singleton Processes with Dependency Injection

A drawing of a supervision tree

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.

iex supervision tree with anonymous and named processes

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:

  1. Do not test the singleton process,
  2. Remove asynchronicity by setting async: false, or
  3. 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!

Want my latest content in your inbox?

    I will never send you spam. Unsubscribe any time.