F5.07 · part 1 of 3
Testing the pure core
The engine's logic lives in pure functions: decide takes a state and a
command and returns events, evolve takes a state and an event and returns the next
state, and replay folds a log into a state. Pure means the same input always gives the
same output and nothing else happens, so a test is three lines — arrange a value,
call the function, assert the result. No process to start, no database to seed,
no mocks to wire. This is the payoff of having pushed the work out of the GenServer and into functions in F5.05
and F5.06: the bulk of the engine is tested without any of the machinery around it.
Three pure functions, three plain tests. Select one to see what it takes and what the test asserts.
Arrange, call, assert
Each function is a value-to-value transformation, so a test gives it an input and checks the output directly. There is no shared state between tests and no order dependence, which is why these run in microseconds and by the hundred. Select a function.
Pure function under test · select one
function: decide/2
the test: asserts the events emitted
A pure function is the easiest thing in the world to test, because there is nothing to test but the mapping from input to output. Keep the core pure and the test suite stays fast and boring.
In code
Three example tests, each a direct value check. decide on a fresh state emits the
enrollment event; evolve folds that event into a state that knows the learner;
replay over a short log lands on the expected state. No setup
block, because there is nothing to set up.
# the pure core needs no process and no mocks — arrange, call, assert test "decide on a fresh state emits LearnerEnrolled" do {:ok, [event]} = Engine.decide(State.new(), {:enroll, "u1", "c1"}) assert %LearnerEnrolled{user_id: "u1"} = event end test "evolve folds an enrollment into the state" do state = Engine.evolve(%LearnerEnrolled{user_id: "u1"}, State.new()) assert State.enrolled?(state, "u1") end test "replay folds a log to the current state" do assert Engine.replay([enrolled("u1"), progressed("u1", 40)]).progress["u1"] == 40 end
purity is testability
Same input, same output, nothing else — so a test is only the mapping, checked.
no machinery
No GenServer to start, no fixtures — the bulk of the suite lives right here.
Next: property-based testing — instead of examples, state an invariant and let a generator find the cases that break it.
Doctests on the pure core
The example tests above live in a test file, separate from the code they exercise. A doctest
moves the example into the function's own documentation. An @doc string holds an
iex> prompt followed by the expected output on the next line; Elixir's
ExUnit.DocTest parses
those blocks and, when doctest/1 names the module in a test case, compiles each one
into a test that evaluates the input and asserts the printed result. The example and the assertion are the same
text.
Because evolve/2 is pure — an event and a state in, the next state out, nothing
else — its behaviour fits on one line and reads as documentation. Attaching the example to its
@doc documents the reducer clause and tests it at once. The lesson from
ExUnit.DocTest: an example in the documentation is executed on every run, so the
documentation cannot drift away from the behaviour it describes — a stale example fails the suite.
# the example lives in evolve/2's own @doc — input, then expected output defmodule Portal.Engine do @doc """ Folds one event onto a state, returning the next state. iex> alias Portal.{Engine, State, LearnerEnrolled} iex> state = Engine.evolve(%LearnerEnrolled{user_id: "u1"}, State.new()) iex> State.enrolled?(state, "u1") true """ def evolve(%LearnerEnrolled{user_id: id}, state), do: State.enroll(state, id) # ... further evolve/2 clauses, one per event ... end
# the test module turns every @doc example in the engine into a test defmodule Portal.EngineTest do use ExUnit.Case, async: true doctest Portal.Engine # => each iex> block becomes one assertion end # mix test # .. # Finished in 0.04 seconds # 2 doctests, 3 tests, 0 failures # => the @doc example ran as a test
A doctest is the example test and the documentation written once; keep the core pure and a single readable line of docs doubles as the test that stops it from going stale.
References
Primary sources for this lesson, and where it connects in the course.
Sources
- Elixir — ExUnit — the test framework.
- StreamData — property-based testing of the pure core.
- Elixir — ExUnit.DocTest — examples in @doc become tests.