Building an event-sourced game with Phoenix Liveview: Unit testing patterns
This article is part of the series Building an event-sourced game in Elixir with Phoenix Liveview. The full list of articles is:
- Building an event-sourced game with Phoenix Liveview: Introduction
- Building an event-sourced game with Phoenix Liveview: Architecture
- Building an event-sourced game with Phoenix Liveview: An event sourced model
- Building an event-sourced game with Phoenix Liveview: Handling errors
- Building an event-sourced game with Phoenix Liveview: Expressing domain concepts in the code
- Building an event-sourced game with Phoenix Liveview: Making game states explicit
- Building an event-sourced game with Phoenix Liveview: Game Server
- Building an event-sourced game with Phoenix Liveview: Acting on the game from the views
- Building an event-sourced game with Phoenix Liveview: Building the view’s states from the events and reacting to changes
- Building an event-sourced game with Phoenix Liveview: Decrementing the timer
- Building an event-sourced game with Phoenix Liveview: Unit testing patterns
We’ve seen a lot about the game but haven’t talked yet about testing. When dealing with event-sourced system testing comes with some interesting patterns. We’ll see some of them in this article.
Testing in event-sourced system
As with everything else with an event-sourced system, testing is based on events. The three tests parts, arrange, act and assert, can be mapped as follow:
Arrange: Create a history of events
Act: Recreate state from events and dispatch a command
Assert: Ensure the system produced some events
Here is an example of a test ensuring that the second player is enlisted in the second team.
defmodule WaitingRoomTest do
use ExUnit.Case, async: true
describe "Add player" do
test "adds second players to the second team" do
# Arrange: create a history where a player already joined the first team
history = [
PlayerJoinedTeam.with(player: %{name: :player_1}, team: 1)
]
# Act: Dispatch a command to add a second player based on the history
# Rebuilding state from history is dealt with by GameState module
%ActionResult{events: events} = GameState.dispatch_message( %AddPlayer{player_name: :player_2}, history)
# Assert: Ensure that an event indicating that the second player joined the second team was produced
assert Enum.member?(events, PlayerJoinedTeam.with(player: %{name: :player_2}, team: 2))
end
end
end
I like my tests to read easily and decided to introduce a new module to take advantage of Elixir’s piping to improve expressiveness. We can build on top of the fact that GameState.dispatch_message
returns an ActionResult
.
The first function in the new module checks if an event was published.
defmodule ActionResultHelper do
def published_event?(%ActionResult{events: events}, event), do:
Enum.member?(events, event)
end
We can rewrite the previous test with this in place once we’ve imported the ActionResultHelper
module.
test "adds second players to the second team" do
history = [
PlayerJoinedTeam.with(player: %{name: :player_1}, team: 1)
]
assert dispatch_message( %AddPlayer{player_name: :player_2}, history)
|> published_events?(PlayerJoinedTeam.with(player: %{name: :player_2}, team: 2))
end
The separation between the act and the test’s assert parts is more blurry, but I think the test better conveys what the system does, so I’m ok with that.
Verify that an error is returned
We can add more functions to the ActionResultHelper
module to verify that the game’s behavior is as expected. One case is ensuring that the game correctly returns an error when needed.
First, let’s add a function.
defmodule ActionResultHelper do
def errored_with?(%ActionResult{error: error}, expected_error), do:
error == expected_error
end
Then, in the tests, we can write something like
test "refuses a player with a name already taken" do
player_name = :same_player_name
history = [
PlayerJoinedTeam.with(player: %{name: player_name}, team: 3),
]
assert %AddPlayer{player_name: player_name}
|> dispatch_message(history)
|> errored_with?(:player_name_not_available)
end
Again, this reads very well!
Ensuring that a message is scheduled
We’ve seen that the application sometimes needs to schedule a message that it would like to receive in the future. We can follow the same pattern to verify that the game logic well produces these messages.
Introduce a new function to the helper module
defmodule ActionResultHelper do
def scheduled_message_in?(%ActionResult{scheduled_messages: scheduled_messages}, message, time), do:
Enum.member?(scheduled_messages, {time, message})
end
And write a test. Here we ensure that when a Tick
message is dispatched the next one is scheduled for one second later.
test "schedules a tick message for 1 second later" do
history = ...
assert %Tick{}
|> dispatch_message(history)
|> scheduled_message_in?(%Tick{}, :timer.seconds(1))
end
Being lazy
Do you speak French and want to stop hating your tests ?
I've created a course to help developers to improve their automated tests.
I share ideas and technics to improve slow, flaky, failing for unexpected reason and hard to understand tests until they become tests we take joy to work with !
But you'll need to understand French...
Sometimes a command creates a lot of events, and it is painful to manually build them all to create a history of a game after several rounds.
We can streamline the arrange part of tests if we use commands to create the history. In that case, we need to collect the events produced when the command is dispatched and add them to the current list of events to complete the history.
defmodule ActionResultHelper do
def start_history(), do:
ActionResult.new()
def dispatch_and_collect_events(%ActionResult{} = history, message) do
history
|> ActionResult.add(GameState.dispatch_message(message, history |> event() , opts))
end
def events(%ActionResult{events: events}), do: events
end
Here I’ve added a few more functions to the helper module. StartHistory
returns an empty %ActionResult{}
structure. The dispatch_and_collect_events
function dispatches a message to the game using the events stored in an ActionResult
and adds events resulting from the dispatch to it. Events
function returns the events contained in the ActionResult
.
We can use these functions to build history. For instance, in the next snippet, we build the history up to after the game starts.
history = start_history()
|> add([ #ActionResult.add
PlayerJoinedTeam.with(player: player(:player_A1), team: 1),
PlayerJoinedTeam.with(player: player(:player_A2), team: 1),
PlayerJoinedTeam.with(player: player(:player_B1), team: 2),
PlayerJoinedTeam.with(player: player(:player_B2), team: 2)
])
|> dispatch_and_collect_events(%StartGame{blue_deck: blue_cards, red_deck: red_cards, dictionary: words})
|> events()
Another instance where it’s super helpful is for the last tick of round, where many things are going on.
With the three following lines, we’re now able to start a round, fast-forward to the penultimate tick and trigger one last tick.
|> dispatch_and_collect_events(%StartRound{})
|> add([RoundTimeTicked.with(remaining_time: 1)])
|> dispatch_and_collect_events(%Tick{})
Being super lazy
When a player marks a word as guessed, the game produces a lot of events. We’ve seen how the introduction of the dispatch_and_collect_events
can help here. One last issue persists when testing that the game correctly ends after 20 words guesses; we need to copy-paste dispatch_and_collect_events(%MarkWordAsGuessed{})
a lot.
We can fix this by adding an extra, optional parameter to dispatch_and_collect_events
, which states how many times we want to repeat the message.
def dispatch_and_collect_events(%ActionResult{} = history, message, opts \\ []) do
repeat = Keyword.get(opts, :repeat, 1)
Enum.reduce(1..repeat, history, fn _x, %ActionResult{events: events} = result ->
result
|> ActionResult.add(GameState.dispatch_message(message, events, opts))
end)
end
This change allows reducing the amount of typing necessary to build the history.
Here we build a history where a player marked 20 words as guessed.
history
|> dispatch_and_collect_events(%MarkWordAsGuessed{}, repeat: 20)
That the last one of the helpful patterns for testing applications built using event-sourcing I’ve introduced in this project. I hope they can give you some ideas for your own projects!