Testing GenServers
Mix.install([
{:jason, "~> 1.4"},
{:kino, "~> 0.8.0", override: true},
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"}
])
Navigation
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 |