Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Testing GenServers

reading/testing_genservers.livemd

Testing GenServers

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.9", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"}
])

Navigation

Home Report An Issue TimerTested Stack

Review Questions

Upon completing this lesson, a student should be able to answer the following questions.

  • How can we test the behavior rather than the implementation of a GenServer?

Testing GenServers

We’ve seen how to perform tests on a module and function, but how do you test something stateful like a process? Let’s consider how we could test a simple CounterServer process that should store an integer in it’s state and increment the value.

defmodule CounterServer do
  use GenServer

  @impl true
  def init(state) do
    {:ok, state}
  end

  @impl true
  def handle_call(:increment, _from, state) do
    {:reply, state + 1, state + 1}
  end

  @impl true
  def handle_call(:get_count, _from, state) do
    {:reply, state, state}
  end
end

Generally, we don’t want to test the implementation of the counter, so we don’t want to specifically send it messages using the GenServer module, nor do we want to test on the state of the process.

For example, the following test is coupled to implementation.

ExUnit.start(auto_run: false)

defmodule CounterServerTest do
  use ExUnit.Case

  test "Counter receives :increment call" do
    {:ok, pid} = GenServer.start_link(CounterServer, 0)
    GenServer.call(pid, :increment)
    assert :sys.get_state(pid) == 1
  end
end

ExUnit.run()

So if any of the internals change, these tests could break, even though the behavior of the counter module doesn’t. Instead, we generally want to test on the client interface of the GenServer.

Here’s a CounterClient module which contains the client interface for the CounterServer module.

defmodule CounterClient do
  def start_link(_opts) do
    GenServer.start_link(CounterServer, 0)
  end

  def increment(pid) do
    GenServer.call(pid, :increment)
  end

  def get_count(pid) do
    GenServer.call(pid, :get_count)
  end
end

Now we can use these functions in our test.

ExUnit.start(auto_run: false)

defmodule CounterClientTest do
  use ExUnit.Case

  test "increment/1" do
    {:ok, pid} = CounterClient.start_link([])
    CounterClient.increment(pid)
    assert CounterClient.get_count(pid) == 1
  end
end

ExUnit.run()

This makes our tests more robust to implementation changes of the CounterServer. So long as the client interface remains the same, then tests will not break.

For example, let’s say we decide to change how we store state in the counter. Instead of an integer, we’ll use a %{count: value} map. We’ve co-located the Client and the Server functions for the sake of example.

defmodule CounterMapExample do
  use GenServer

  # Client

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{count: 0})
  end

  def increment(pid) do
    GenServer.call(pid, :increment)
  end

  def get_count(pid) do
    GenServer.call(pid, :get_count)
  end

  # Server

  @impl true
  def init(state) do
    {:ok, state}
  end

  @impl true
  def handle_call(:get_count, _from, state) do
    {:reply, state.count, state}
  end

  @impl true
  def handle_call(:increment, _from, state) do
    {:reply, state.count + 1, %{state | count: state.count + 1}}
  end
end

Our tests still pass, because the behavior and interface has not changed.

ExUnit.start(auto_run: false)

defmodule CounterMapTest do
  use ExUnit.Case

  test "increment/1" do
    {:ok, pid} = CounterMapExample.start_link([])
    CounterMapExample.increment(pid)
    assert CounterMapExample.get_count(pid) == 1
  end
end

ExUnit.run()

However, the old test suite breaks, because it was coupled to the underlying implementation.

ExUnit.start(auto_run: false)

defmodule CounterMapExampleTest do
  use ExUnit.Case

  test "handle_call :increment" do
    {:ok, pid} = GenServer.start_link(CounterMapExample, 0)
    GenServer.call(pid, :increment)
    assert :sys.get_state(pid) == 1
  end
end

ExUnit.run()

For more on testing GenServers, there is an excellent talk by Tyler Young.

YouTube.new("https://www.youtube.com/watch?v=EZFLPG7V7RM")

Your Turn

Implement and test a decrement/1 function on the CountDown module.

defmodule CountDown do
  use GenServer

  # Client

  def start_link(_opts) do
    # Implementation
  end

  def decrement(pid) do
    # Implementation
  end

  # Server

  @impl true
  def init(state) do
    {:ok, state}
  end

  @impl true
  def handle_call(:get_count, _from, state) do
    # Implementation
  end

  @impl true
  def handle_call(:decrement, _from, state) do
    # Implementation
  end
end

ExUnit.start(auto_run: false)

defmodule CounterTest do
  use ExUnit.Case

  # implement your test
  test "decrement/1"
end

ExUnit.run()

Commit Your Progress

DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.

Run git status to ensure there are no undesirable changes. Then run the following in your command line from the curriculum folder to commit your progress.

$ git add .
$ git commit -m "finish Testing GenServers reading"
$ git push

We’re proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.

We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.

Navigation

Home Report An Issue TimerTested Stack