Files
atomic-design-poc/docs/reference/architecture/0006-test-data-builders.md
T
ehoandClaude Sonnet 5 a82332fa20 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>
2026-08-18 15:31:20 +02:00

165 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.