docs: ADR-0006 test-data builders, close out WP-70

Writes up the principle behind WP-70's three tracks ("build test data
through the same door production code uses") as ADR-0006, with a decision
table for which fixture idiom fits which test type. Updates the
test-strategy skill (adds the Fixtures rule, fixes its stale pre-monorepo
src/app/... worked-example paths) and the shared Storybook testing.mdx page
to match. Closes WP-70 with the signatures/counts as actually shipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-18 15:31:20 +02:00
co-authored by Claude Sonnet 5
parent a7bf228ca8
commit a82332fa20
5 changed files with 327 additions and 4 deletions
@@ -0,0 +1,164 @@
# ADR-0006 — Test data through the production door (builders, replay, and where each applies)
Status: Accepted · Date: 2026-08-18
## Context
Decision #3 in `CLAUDE.md` is "make illegal states unrepresentable," and the production code
mostly honours it: `AanvraagStatus` (backend) is a `sealed class` with a private constructor
reachable only through five static factories; the frontend's wizards are tagged-union state
machines driven by a pure `reduce`; form inputs are branded value objects reachable only
through a `parse*` that returns `Result`.
The test suites are the one place this invariant is not enforced — they build fixtures by
hand instead of through those same doors:
- **Backend.** `Aanvraag` (`Data/ApplicationStore.cs`) is a mutable EF-backed bag: `Submitted`,
`Referentie`, `BesluitStatus`, `SubmittedAt` are independent public setters. Its own
`StatusAt` dereferences `Referentie!` three times — "Submitted ⇒ Referentie != null" is
convention, not type. Two test files (`RuleTests.cs`, `OpenZaakZaakSourceTests.cs`) kept
eight such fixtures internally consistent by hand, each re-deciding for itself which fields
a given scenario needs.
- **Frontend.** No shared fixture helper existed anywhere in `apps/` or `libs/`. Every spec
redefined its own throwaway literal function (`editing1/editing2/editing3`, `editingWith`,
a local `ok()`), each hardcoding fields like `errors: {}` — asserting against a shape the
real reducer may never actually produce, because the literal skips the reducer entirely.
- **E2E.** The one seeded citizen's BSN and a diploma id were duplicated as bare string
literals across every spec, coupled to `SeedData.cs`'s exact ordering by comment only, with
no compiler check if the seed ever changed shape.
A hand-rolled literal is not "faster test setup" — it is a second, unchecked implementation
of the domain's construction rules, sitting right next to the real one.
## Decision
**Build test data through the same door production code uses. A test-data helper's job is to
supply _defaults_, never to bypass _invariants_.**
Concretely: reject any test helper shaped as a field-by-field builder (`.withX().withY()...`
over an otherwise-open constructor) — that is an object literal with extra syntax, and it
re-opens every illegal state the production type closed. Each layer instead gets the
narrowest helper that **cannot** construct an illegal instance, because it has no path to one.
### 1. Backend aggregates with a lifecycle → a type-state builder
Where a production type enforces its invariants (or should), the test builder mirrors that
enforcement as separate **types per stage**, so an illegal call is a compile error, not a
runtime surprise:
```csharp
Given.Concept() // ConceptAanvraag — only .Submitted() or .Build() exist
.Submitted() // SubmittedAanvraag — only .Decided() or .Build() exist
.Decided(Besluit.Afwijzen, "reden"); // DecidedAanvraag
```
`Given.Concept().Decided(...)` does not compile — `Decided` is simply not a member of
`ConceptAanvraag`. Where the production rule is more subtle than "which methods exist"
(e.g. "Afwijzen requires a toelichting"), the builder **calls the real production rule**
(`BeoordelingRules.RequiresToelichting`) rather than re-stating it — this is what keeps the
builder from drifting out of sync with the domain as the domain changes.
Use this shape whenever a production aggregate has an ordered lifecycle and either (a)
already guards it with factories (mirror them 1:1), or (b) doesn't yet guard it (as with
`Aanvraag` itself, see Consequences) — the test-only builder is not a substitute for fixing
the production type, but it stops the test suite from being the place the ungated shape leaks
out into assertions.
### 2. Frontend state machines → replay real messages through the real reducer
No object is built directly. A fixture is the result of running real `Msg`s through the real
`reduce`:
```ts
export const given =
<S, M>(reduce: (s: S, m: M) => S, initial: S) =>
(...msgs: M[]): S =>
msgs.reduce(reduce, initial);
export const givenIntake = given(reduce, initial); // per-context wrapper, pure TS
```
There is no way to hand-write a `Submitting` state whose draft contradicts its step, or to
assert `errors: {}` into existence — the only states reachable are the ones the reducer can
actually produce, because production is the only code path that produces them.
### 3. Value objects → `unwrapOk`, never a cast
A test that needs a valid branded value calls the real `parse*` and unwraps it:
```ts
export const unwrapOk = <E, T>(r: Result<E, T>): T => {
if (!r.ok) throw new Error('unwrapOk: parser rejected the input');
return r.value;
};
const postcode = unwrapOk(parsePostcode('1234 AB'));
```
This closes the `'garbage' as Postcode` route — a spec can only ever hold a value the real
parser accepted.
### 4. RemoteData → named constructors, not ad-hoc literals
`loading()` / `success(v)` / `failure(e)` in `libs/shared/src/testing/remote-data.ts` replace
the per-spec local `ok()`/`loading`/`failure` literals. `RemoteData` has no invariant to
protect (it's a plain closed union with no smart constructor in production either), so this
one is about **removing duplication**, not closing an illegal-state gap — named constructors
belong here because they are shorter and consistent, not because the literal was unsafe.
### 5. E2E — shared actors/seed-refs, not a DSL
E2E fixtures are named, not built: `Actors.zorgverlener`, `SeedRefs.diplomaZonderPolicyVragen`
in `e2e/support/actors.ts`, with `loginAs(page, actor)` replacing the duplicated login
sequence. No page-object layer, no Given/When/Then runner — Playwright specs stay flat
`page.getByRole` sequences (matching WP-19's "smoke, not full coverage" scope), the only
change is that the values they use have one source instead of N copies. See "Where this does
**not** reach" below for why the deeper e2e problem is out of scope here.
## Decision table — what to reach for, by test type
| Test type | Where it lives | Fixture idiom | Do **not** |
| -------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Domain aggregate with a guarded lifecycle (backend) | `*.Tests/Builders/` | Type-state builder mirroring the production factories; delegate any non-trivial rule to the real rule class | A field-by-field `.WithX()` builder, or an object initializer with all fields public |
| Pure reducer / state machine (frontend) | `domain/*.testing.ts` | `given(reduce, initial)(...msgs)` — replay real messages | A literal returning `{ tag: 'Editing', ... }` by hand |
| Value object / parser | co-located with the parser's spec | `unwrapOk(parseX(raw))` | `'x' as BrandedType` |
| Plain closed union with no invariant (e.g. `RemoteData`) | `libs/shared/src/testing/` | Named one-line constructors (`loading()`, `success(v)`) | Redefining the same literal per spec file |
| Trust-boundary `parse*` (adapter) | co-located, per `test-strategy` skill | Hand-written DTO literals **are** correct here — the point of the test is "what if the untrusted shape is wrong," so the fixture must be a raw, possibly-malformed literal, not a validated domain value | Routing malformed-input tests through a builder that can't express malformed shapes |
| UI component | Storybook story + axe | Args as `input()`s on the component; no fixture builder needed | A component test with a hand-built store/model |
| Acceptance / behaviour test (either side) | `Acceptance/*Tests.cs` (backend), `*.acceptance.spec.ts` (frontend) | The same builder/replay idiom as above, composed into one Given→When→Then read | A separate BDD/Gherkin runner — the language's own test framework plus the builder is enough |
| E2E | `e2e/support/` | Named actor/seed-ref constants + a thin `loginAs`-style setup helper | A page-object framework or DSL — out of proportion to a 3-spec smoke suite |
The common thread: **the fixture idiom is only ever a thinner or safer path to the same
construction the domain already performs** — never a parallel, unchecked one. The trust-
boundary row is the deliberate exception, not a contradiction: there the entire point of the
test is to exercise what happens when the input _isn't_ valid, so the fixture must be able to
represent the invalid shape a builder would refuse to construct.
## Consequences
- **+** An illegal backend fixture (e.g. a decided-but-not-submitted `Aanvraag`) is now a
compile error in the builder path, not a silent bad test.
- **+** Frontend specs can no longer assert against a state the reducer cannot actually reach;
a hardcoded `errors: {}` fixture literal can't drift from what validation actually produces.
- **+** One seeded identity/diploma reference in e2e instead of N copies — a reseed shows up as
one changed constant, not a hunt through three spec files.
- **** `Aanvraag` itself is **not** made illegal-states-unrepresentable by this ADR — it
remains a mutable EF-backed class (WP-68 kept it that way deliberately; `ApplicationStore`
is its only production writer). The builder is a test-only enforcement layer sitting in
front of a production type that still allows the bad shape directly. Closing that gap for
real means an EF-mapping change, tracked as a follow-up, not done here.
- **** A type-state builder is more ceremony than a constructor call for a one-off fixture.
Reach for it only where a lifecycle actually has ordered stages worth protecting — a flat
value type doesn't need one (see the `RemoteData` row above).
## Where this does **not** reach (deliberately out of scope)
- **E2E test isolation.** The three Playwright specs share one mutable backend and admit it in
their own comments ("restart the backend between CI runs"). The real fix is a dev-only seed
endpoint each test can call to build its own isolated citizen/aanvraag — a new
production-adjacent surface that needs its own security review, not a fixture-idiom change.
Tracked as a follow-up; not fixed here.
- **`RegistrationStatus`** (`Domain/Registrations/`) has the same class of gap as `Aanvraag`
a flat record with four always-present nullable fields, whose own doc-comment says only one
tag ever uses the deadline field — but is out of this ADR's scope (a separate WP).
- **`apps/behandelportal` e2e coverage** is currently zero; adding it is a coverage gap, not a
fixture-idiom question, and is a separate follow-up.