Testing strategy — catch the class, not the instance
Context
Section titled “Context”#475 shipped with green CI. vitest, svelte-check, the “Playwright standalone” job (~30 specs), and the full-stack integration suite all passed while every asset click inside a collection 404’d. The suite had the pieces and still missed it:
ui-15-post-detail-modalclicks through to the viewer — but only for posts (the path that works).ui-18-collection-fieldsopens a collection — but only to test the fields editor; it never clicks an asset tile.ui-29-not-foundasserts a bogus URL renders a friendly 404 — it never asserts that a real navigation target does not 404. That is the exact inverse of what #475 needed.
The pattern is that the expensive bugs live in the seams between working parts — a dead internal link, a row-plane/content-plane disagreement (#460/#476), a viewer that doesn’t handle gated content (#471). Unit tests over individual components pass; the integration of “real user clicks real link, real surface renders” is untested. The operator’s direction was explicit: “I want tests that are robust. I don’t need quick, I need right. I don’t want to run into an issue that could have been caught with a test.”
Decision
Section titled “Decision”A layered testing standard. Each layer catches a class of defect the layers above cannot, and the cheap layers do not excuse skipping the expensive ones — robustness over speed.
-
Regression-with-fix (non-negotiable). Every bug fix ships a test in the same PR that is red without the fix and green with it. A fix without a failing-first test is incomplete. #475’s fix (ADR 0067) ships with layers 2 and 3 below.
-
Static route/link integrity (CI, no stack). A check that enumerates every internal
href="/…"literal inweb/srcand resolves it against the SvelteKit route table (src/routes/**/+page, honoring[param]and route groups), failing on any target with no matching route. Catches the entire dead-link class — including #475 — in seconds, with no running stack. This is the net #475 most obviously lacked. -
E2E golden-path coverage (seeded stack). Playwright specs that walk the real navigation flows end to end and assert both that the surface renders and that no
4xxto/api/and no error boundary fired: browse→collection→asset→viewer, post→viewer, featured→target, search→result. Known-benign console noise (e.g. the analytics beacon’s CORS line) is allowlisted so the assertion fails only on real defects. The seeded stack — the same dataset the demo runs — is the fixture. A route with zero golden-path coverage is a reported gap, not a silent one. -
Plane-consistency integration (Go). For each entity, tests asserting the row plane and content plane agree: a caller denied the row is denied the content, and the serving endpoints (variants/file/iiif) enforce the same predicate the browse path does — across the anon / authenticated / limited-viewer matrix. This is where #460/#476 live.
Principle: prefer catching the class over the instance. A per-instance regression test (layer 1) proves this bug is dead; the class-level nets (layers 2–4) prevent its siblings. Both are required — the instance test documents intent, the class net prevents recurrence.
Consequences
Section titled “Consequences”- v0.5.1 ships #475’s fix with layers 1–2 (regression E2E + the link-integrity check), because “the test accompanies the fix” and the link-integrity check is the class-level net for exactly this bug.
- v0.6.0+ builds out layer 3 (golden-path E2E across the seam flows) and layer 4 (plane-consistency + role matrix), as a coverage epic — informed by, not blocking, the patch.
- Open bugs #471 / #476 / #470 each carry their own red-first regression test into their fix PR.
- A green CI that skipped a layer is not “covered” — the layer’s absence is a tracked gap, logged, never silent.
References
Section titled “References”- ADR 0058 — demo seed dataset (the golden-path E2E fixture).
- ADR 0067 — the /assets/[id] route whose fix pilots this standard.
- #475 — the bug that shipped green and motivated this ADR.