Powered by AppSignal & Oban Pro

Testing GenServers

reading/testing_genservers.livemd

Testing GenServers

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

Navigation

Return Home Report An Issue

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?

Setup

Ensure you type the ea keyboard shortcut to evaluate all Elixir cells before starting. Alternatively you can evaluate the Elixir cells as you read.

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()

Mark As Completed

file_name = Path.basename(Regex.replace(~r/#.+/, __ENV__.file, ""), ".livemd")

save_name =
  case Path.basename(__DIR__) do
    "reading" -> "testing_genservers_reading"
    "exercises" -> "testing_genservers_exercise"
  end

progress_path = __DIR__ <> "/../progress.json"
existing_progress = File.read!(progress_path) |> Jason.decode!()

default = Map.get(existing_progress, save_name, false)

form =
  Kino.Control.form(
    [
      completed: input = Kino.Input.checkbox("Mark As Completed", default: default)
    ],
    report_changes: true
  )

Task.async(fn ->
  for %{data: %{completed: completed}} <- Kino.Control.stream(form) do
    File.write!(
      progress_path,
      Jason.encode!(Map.put(existing_progress, save_name, completed), pretty: true)
    )
  end
end)

form

Commit Your Progress

Run the following in your command line from the curriculum folder to track and save your progress in a Git commit. Ensure that you do not already have undesired or unrelated changes by running git status or by checking the source control tab in Visual Studio Code.

$ git checkout -b testing-genservers-reading
$ git add .
$ git commit -m "finish testing genservers reading"
$ git push origin testing-genservers-reading

Create a pull request from your testing-genservers-reading branch to your solutions branch. Please do not create a pull request to the DockYard Academy repository as this will spam our PR tracker.

DockYard Academy Students Only:

Notify your teacher by including @BrooklinJazz in your PR description to get feedback. You (or your teacher) may merge your PR into your solutions branch after review.

If you are interested in joining the next academy cohort, sign up here to receive more news when it is available.

Up Next

Previous Next
Monster Spawner Stack GenServer Testing