jonnifyelixir
F5 / F5.07 / contract-tests

F5.07 · part 3 of 3

Contract tests

The contract from F5.04 was three promises about a command: a precondition the caller must meet, a postcondition the command guarantees, and an invariant always true of the state. Written as documentation they describe behaviour; written as tests they enforce it. A precondition test feeds a bad command and asserts {:error, reason} with no state change; a postcondition test runs a valid command and asserts the guarantee; an invariant test asserts the rule on the resulting state. And doctest runs the @doc examples as tests, so the documentation cannot drift from the code. The contract stops being a comment and becomes something the suite checks.

Three promises, three assertions. Select a term to see what its test feeds in and checks.

A contract you can run

Each clause of the contract maps to an assertion. The precondition becomes a refused bad input; the postcondition becomes a checked guarantee; the invariant becomes a property of the result. Doctests cover the examples in between. Select a term.

Contract term as a test · select one

PRECONDITION bad command → {:error, reason} · no state change caller must meet POSTCONDITION valid command → the event is emitted command guarantees INVARIANT resulting state → progress in 0..100 always true of state

term: precondition

the test asserts: bad input returns {:error, _}

A contract in a comment is a hope; a contract in a test is a guarantee. The same three terms from F5.04, run on every commit, keep the engine honest.

In code

The contract terms as assertions, and a one-line doctest. The precondition test refuses a second enrollment; the invariant test records progress and checks the bound; and doctest runs every @doc example in the engine module so the documentation stays true.

# the F5.04 contract, now run as tests
test "enrolling twice is refused (precondition)" do
  state = Engine.evolve(%LearnerEnrolled{user_id: "u1"}, State.new())
  assert {:error, :already_enrolled} = Engine.decide(state, {:enroll, "u1", "c1"})
end

test "recording progress keeps it in 0..100 (invariant)" do
  {:ok, [event]} = Engine.decide(state, {:record_progress, "e1", 40})
  assert State.progress(Engine.evolve(event, state), "e1") in 0..100
end

doctest Portal.Engine   # the @doc examples run as tests, so the docs cannot drift

contract to assertion

Precondition, postcondition, invariant — each a line the suite checks, not prose.

docs that run

doctest runs the @doc examples, so they cannot go stale.

That closes F5.07: the pure core is tested by example, properties guard the invariants over generated inputs, and the F5.04 contract runs as tests. The next module, F5.08 — Boundaries & integration seams, wraps the engine behind ports and a facade with error shapes the UI can render. Back to the module overview or the chapter.

References

Primary sources for this lesson, and where it connects in the course.

Sources

Related in this course