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:
@@ -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.
|
||||
Reference in New Issue
Block a user