# 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 = (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 = (r: Result): 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. ## A note on Given/When/Then and `bdd.mdx` This ADR's "Given.Concept()...Build()" builder chain and the acceptance-test row above ("composed into one Given→When→Then read") already used Given/When/Then before it was the repo-wide default. `libs/shared/docs/bdd.mdx` has since made G/W/T structure — a `// Given` / `// When` / `// Then` comment (or, in TypeScript, the equivalent unlabelled ordering) inside every test body, acceptance or not — the documented convention for **all** tests, not only acceptance ones (reversing its own earlier "no G/W/T ceremony" rule). The two documents now agree everywhere: this ADR's `Given` builder is the fixture idiom; `bdd.mdx` rule 1 is the structural convention every test using that fixture (and every other test besides) follows. ## 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.