Files
atomic-design-poc/docs/project/archive/backlog/WP-70-test-data-builders.md
T
ehoandClaude Opus 5 12f17d9d73 docs: archive the finished backlogs (RD-30)
Two backlog trees are complete: `docs/project/backlog/` (75 files, every
WP done) and `docs/project/refactor-backlog-setup/` (the arc before it).
Move both under `docs/project/archive/` with `git mv`, so history stays
intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them,
because it points at the now-archived backlog README.

Add `docs/project/archive/README.md`. It states that these trees are
historical and names the two directories that are still live.

Repoint every inbound reference named in RD-30's Files table: CLAUDE.md,
the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the
`document-feature` and `new-ssp` skills, and the readable-codebase PLAN,
README, and RD-19 ticket. Fix two upward-relative links inside the moved
WP files (WP-68, WP-69) that gained a directory level and would otherwise
break. Repoint `.prettierignore`'s two agent-prompt exclusions to their
new path, so prettier keeps leaving those files' exact wording alone.

Mark RD-30 done and check off its acceptance criteria; flip its README
row to done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:00:38 +02:00

7.7 KiB

WP-70 — Test-data builders: illegal fixtures unrepresentable

Status: done (2eea860..a82332f) Phase: 12 — DDD hardening

Why

Decision #3 in CLAUDE.md — "make illegal states unrepresentable" — is honoured in production code (AanvraagStatus's private-ctor/factory shape, the FE's tagged-union machines, branded value objects behind parse*) but not in the test suites that exercise them. Every layer independently reinvented ad-hoc, hand-built fixtures that reach around the production construction path:

  • Backend: Aanvraag is a mutable EF-backed bag with independent public setters. Its own StatusAt dereferences Referentie! three times on the unstated assumption "Submitted ⇒ Referentie != null" — a convention two test files (RuleTests.cs, OpenZaakZaakSourceTests.cs) kept consistent by hand across eight inline fixtures.
  • Frontend: no shared fixture helper existed anywhere. Every machine spec redefined its own throwaway literal helper (editing1/2/3, editingWith), each hardcoding fields like errors: {} — asserting against shapes the real reducer may never produce.
  • E2E: the seeded BSN and a diploma id were copy-pasted across all three specs, coupled to SeedData.cs's exact shape by comment only.

Read first

  • ADR-0006 (docs/reference/architecture/0006-test-data-builders.md) — the principle and the full decision table this WP implements.
  • CLAUDE.md §"The decisions" #3, #5.
  • backend/src/BigRegister.Api/Data/ApplicationStore.cs (Aanvraag, StatusAt).
  • backend/src/BigRegister.Api/Domain/Applications/AanvraagStatus.cs — the exemplar this WP's backend builder mirrors.
  • backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs.

Decisions (pre-made, don't relitigate)

  1. No With*-per-field builders anywhere. A builder that opens every field back up is an object literal with extra syntax — reject that shape on either side of the seam.
  2. Backend: a type-state builder. Given.Concept()ConceptAanvraag (only .AtStep/ .Submitted/.Build exist) → SubmittedAanvraag (only .Decided/.Build exist) → DecidedAanvraag. Decided(...) validates a toelichting by calling the real BeoordelingRules.RequiresToelichting, not by re-stating the rule.
  3. Aanvraag itself stays mutable — WP-68 deliberately kept it an EF-backed class; fixing that for real is an EF-mapping refactor, out of scope here (see Follow-ups).
  4. Frontend: replay, don't fabricate. One combinator, given(reduce, initial)(...msgs) (libs/shared/src/testing/machine.ts), replaces every hand-written state literal. Value objects: unwrapOk(parseX(raw)), never a cast. RemoteData: named constructors (loading()/success(v)/failure(e)), replacing duplicated per-file literals.
  5. E2E stays a flat smoke suite (WP-19's scope). Only extract shared Actors/SeedRefs/ loginAs — no page-object layer, no Given/When/Then runner, no dev-only seeding API. The shared-mutable-backend isolation problem is a documented follow-up, not fixed here.
  6. Convert worst offenders only, not a full sweep: RuleTests.cs's Decided() helper + OpenZaakZaakSourceTests.cs's seven inline initializers (backend); herregistratie.machine.spec.ts + change-request.machine.spec.ts + both RemoteData specs (frontend); all three e2e specs (actors/seed-refs only).

Files

Area Path
New (BE) backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs, Acceptance/BesluitLifecycleTests.cs
Edit (BE) RuleTests.cs, OpenZaakZaakSourceTests.cs
New (FE) libs/shared/src/testing/{machine,remote-data,value-object}.ts, herregistratie/domain/intake.testing.ts, intake.acceptance.spec.ts
Edit (FE) herregistratie.machine.spec.ts, change-request.machine.spec.ts, remote-data.spec.ts, machine-remote-data.spec.ts, both tsconfig.app.json, angular.json
New (e2e) e2e/support/actors.ts
Edit (e2e) smoke.spec.ts, brief-v2.spec.ts, error-state.spec.ts
Docs ADR-0006, libs/shared/docs/testing.mdx, .claude/skills/test-strategy/SKILL.md, this file + backlog README row

Steps

Executed as three file-disjoint parallel tracks (backend / frontend / e2e), each ending its own layer's tests green, then a combined gate, then docs written up against the interfaces as actually shipped.

Acceptance criteria

  • Given.Concept().Decided(...) does not compile (proved live: temporarily inserted the call, confirmed dotnet build fails with CS1061, reverted).
  • Decided(Besluit.Afwijzen)/MeerInfoOpvragen with no toelichting throws, via the real BeoordelingRules.RequiresToelichting.
  • Backend tests: 220/220 passing (was 216 before; +4 from BesluitLifecycleTests).
  • Frontend: npm test green across all four projects (ssp/behandelportal/shared/beheer); converted specs assert the same behaviour as before (diffed, not just re-passed) — one case (editing3's hardcoded errors: {} at step 3 with invalid punten) was confirmed reachable via SetField after Next, not an unrepresentable state, so the assertion carried over unchanged.
  • No fixture-only export (givenIntake etc.) leaks into a production bundle — confirmed via grep -rl on dist/ after both a plain and a --localize build.
  • npm run ci green (lint, format, tokens, both localized builds, audit, backend format+test, snippet-drift, api-client-drift).
  • [~] npm run e2e — refactor reviewed line-by-line (zero assertions changed), but not run to completion in this environment: port 4200 was occupied by an unrelated container (team-monitor-web-1), not this repo's stack. Confirm on a clean runner/CI before relying on it; not a regression introduced by this WP.

Verification

cd backend && dotnet format --verify-no-changes && dotnet test   # 220/220
npm run ci                                                       # green (2026-08-18)
npm run e2e                                                      # run on a clean port 4200

Out of scope

  • E2E test isolation (a dev-only seed endpoint) — the real fix for the shared-mutable-backend problem; a new production-adjacent surface needing its own security review.
  • Making Aanvraag itself illegal-states-unrepresentable (EF-mapping refactor).
  • RegistrationStatus's equivalent flat-record gap (Domain/Registrations/) — same class of defect, separate WP.
  • E2E coverage for apps/behandelportal (currently zero).

Risks

  • The backend type-state builder only guards the fields it models (Submitted, Referentie, SubmittedAt, BesluitStatus, BesluitToelichting); other Aanvraag fields (e.g. ZaakUrl) are still set post-.Build() directly, since Aanvraag remains mutable. A future field added to the lifecycle needs a deliberate builder update, or it silently reopens the same gap this WP closed.