jonnifyelixir
F5 / F5.07 / pure-core

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

DECIDE/2 (state, command) → {:ok, events} | {:error, reason} assert events EVOLVE/2 (event, state) → next_state assert next state REPLAY/1 events → state  (Enum.reduce over evolve) assert folded state

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

Related in this course