We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
9. Testing Event-Sourced Systems
We have built a game and a few different storage programs now we can do the thing that we need to ensure that we have all the right pieces, TESTING!!! This should be as easy as writing tests for pure functional programming. Let’s get started
Testing Aggregates
These are designed to accept commands and then depending on the state return 0 or more events. The state might look like this.
f(state, command) = e1 ... en
One thing to keep in mind is that a proper separation of concerns is very important here. Aggregates should be able to test for commands and then issue events. That is it. Let’s take a look at a simple Elixir code to test for an overdraft after a withdrawal.
defmodule AggregateTest do
use ExUnit.Case
test "produces overdraft event on negative balance" do
state = %{balance: 50, account_number: "B00100"}
cmd = %Bank.WithdrawCommand{account_number: "B00100", amount: 100}
assert BankAggregate.handle_command(state, cmd) == [
%{
event_type: :amount_withdrawn,
amount: 100,
account_number: "B00100",
effective_balance: -50
},
%{
event_type: :overdraft,
account_number: "B00100",
overage: 50
}
]
end
end
You might also want to test that overdrafts get stopped, that assert that deposits and withdrawals happen correctly. You could then make sure that the state is being updated as well, but that is a pit-fall which brings up another rule, “Never Test Internal State”.
f(state, event) = state'
In an ideal test environment, never test the internal state and merely hold onto the state like an opaque token. Let’s rewrite the last test with this in mind.
defmodule AggregateTest do
use ExUnit.Case
test "produces overdraft event on negative balance" do
initial_state = BankAccount.new("B00100")
events = BankAccount.handle_command(
initial_state,
%Bank.Deposit{account_number: "B00100", amount: 50}
)
state1 = BankAccount.apply_events(initial_state, events)
events2 = BankAccount.handle_command(
state1,
%Bank.Withdraw{account_number: "B00100", amount: 100}
)
assert events2 == [
%{
event_type: :amount_withdrawn,
amount: 100,
account_number: "B00100",
effective_balance: -50
},
%{
event_type: :overdraft,
account_number: "B00100",
overage: 50
}
]
end
end
So we can now see that the internal state is kept within a variable and will not be something that we test against or will put raw values into. State can be variable and the Events and Commands represent a fixed public API.
Testing Projectors
For projectors you will be testing external state. Something that will be consumed by third parties. In this way you will need to create tests that assert the shape of the projections that you want to match.
There might be times which you are building projection code that you will want to automatically write the projection with the projection, but for testing you will want to have an other process that takes care of the write. That way you can just test against the shape of the projections.
defmodule ProjectorTest do
use ExUnit.Case
test "projector subtracts withdrawals" do
projections = BankAccountProjections.empty()
evts = [
%{event_type: :account_created, account_number: "B00100"},
%{event_type: :amount_deposited, account_number: "B00100", amount: 500}
]
projections2 = BankAccountProjections.apply_events(projections, evts)
assert projection2 == %{
account_number: "B00100",
balance: 500
}
end
end
We can see that this will hold up against the test. But there might be a better way of modeling the data. We don’t want to have to always have access to all the past events in order to add and subtract from a projection. There is a better way to model this, this will have to go back to the way we keep the projection state.
defmodule ProjectorTest do
use ExUnit.Case
test "projector subtracts withdrawals - take 2" do
evts = [
%{event_type: :account_created, account_number: "B00100"},
%{event_type: :amount_deposited, account_number: "B00100",
amount: 500,
effective_balance: 500}
]
end
end
This one stores the balance within the projection, this might not always be a allowed state, there might be PII that you will have to deal with, there might also not always have all the previous states like in the leaderboard example. So keep that in mind.
Testing Process Managers
A Process Manager ingests events and produces commands. Let’s take a look at the lunar lander and see a PM test.
defmodule ConstructionProcessTests do
use ExUnit.Case
test "process manager spawns buildings" do
input = %{event_type: :construction_completed,
site_x: 0,
site_y: 2,
owner: "bob",
site_type: :barracks}
assert ConstructionManager.handle_event(%{}, input) ==
%SpawnBuilding{
building_type: :barracks,
x: 0,
y: 2,
owner: "bob"
}
end
end
Remember that commands are supplied to aggregates, and the aggregates are responsible for validating those commands, against the current state. The process advancement or termination is done by process managers.
Put another way. You will only be testing against the commands and not the validity of the command. You might also want to test against the whether or not the PM should advance or terminate.
Using Automated and Acceptance Testing
You have taken individual tests and made sure the single process is doing what it should. We now need to take a holistic view of events. There will be logic flows that need to be maintained here. We will create a very pseudo code for a projector, but the idea is there and it will take a lot of things we did and build off of those. Without these higher level test you might never find a logic flow issue.
defmodule ProjectorTest do
use ExUnit.Case
test "new player flow" do
cmds = [
create_game(),
create_player(),
advance_turn(5)
]
Flow.inject(cmds)
assert_event: :game_created
assert_event: :player_created
assert_event: :construction_advanced
assert_event: :construction_completed
assert_event: :building_spawned, &(:building_type == :headquarters)
end
end
What we want to see here is after the advancement that the player is given their headquarters. If there is something that doesn’t go green you will be able to check against all the components and see where the issue is and refactor until it all goes green. There might not be a need to test that all the events were generated, or passed, if there is an issue along the way you would see red and know there is an issue with the associated logic.
Wrapping Up
With all this being said you might want to ensure that you are getting the right library or tooling that makes testing a lot easier. But if you want to build your own tests from scratch be sure to separate out all the concerns. Build the aggregate to test just aggregates. Once you have all the pure functionality tested, move onto logic flow and see that after iterations and ticks everything works out.