10. Testing Strategy
The frontend test pyramid — unit / hook / component, type-level, Storybook + accessibility (a11y), and Playwright end-to-end (E2E). Part of the Frontend Architecture reference.
Read this if you are adding a test and need to know which layer it belongs in, or which checks run in CI versus locally.
The frontend ships a pyramid that matches the architecture's seams. Today it comprises roughly 145 colocated *.test.ts(x) under apps/web/src (66 .test.ts + 79 .test.tsx, of which 12 are colocated *.a11y.test.tsx), 9 type-level *.test-d.ts under apps/web/test/types, 12 Playwright *.spec.ts under apps/web/e2e, and ~88 *.stories.tsx Storybook stories.
10.1 The Pyramid
10.2 Unit Tests (Vitest)
What is tested in isolation:
- Query-key factories — assert structural equality of generated keys for representative inputs. Ensures factory shape changes are caught.
- The invalidation router —
handleEvent(event, mockQueryClient)for each event type; assert exact set ofinvalidateQueriesandsetQueryDatacalls. This is the most important unit test in the app — it is the contract surface between the backend's events and the frontend's cache. - Event-handler parity (
contexts/operations/every-event-has-handler.test.ts) — iterates the runtimeDOMAIN_EVENT_TYPESarray and asserts a handler is registered for every variant; flags obvious empty stubs. Backstop to theRecord<DomainEventType, InvalidationHandler>compile-time check (§7.4). Mirrors the backend'sscripts/check-domain-type-parity.pypattern. - Stage-state parity (
contexts/pipeline/components/every-stage-state-has-badge.test.tsx) — iteratesSTAGE_STATE_KINDSand asserts<StageBadge>renders a non-default arm for every kind; backstop to the exhaustiveswitchonstate.kind. - Selectors — pure functions that derive presentation shape from projections (e.g.,
groupArtifactsByJob,summarizeFunnel). - Contracts Zod schemas — round-trip the
@jobctrl/contractsrequest / search-param schemas (parse → typed → serialize). The SSEDomainEventunion is plain TypeScript, not Zod, so it is not among these (§7.2).
These tests do not mount React components.
10.3 Component & Hook Tests (Vitest + React Testing Library + MSW)
Decision (resolves §6 question 14): Both domain hooks (with MSW) and end-to-end Playwright. The line:
- Hook tests with MSW: domain hooks (
useJobsListQuery,useApplyJobMutation, etc.) — assert that the hook calls the right API method, returns the typed shape, invalidates the right keys on success, and rolls back on error. Per-hook coverage is the standard, though not yet universal — many, not all, of the query / mutation hooks have a colocated test today. The digest hook pair is covered here: the query readsGET /v1/digest, while the acknowledge mutation verifies the explicit timestamp payload and digest-key invalidation. - Component tests with MSW: for components with non-trivial interaction (filter bar binding to URL state, bulk select toolbar, apply timeline). Render with a router and a query client; drive via RTL
userEvent; assert observable DOM state.<DigestPanel />follows this layer by asserting URL-owned deep links and the explicit "mark reviewed" acknowledge action. - Playwright E2E: smoke flows only — navigate the dashboard, filter a jobs list, open a drawer, trigger a dry-run apply. Run against a real
apps/api+ a seeded SQLite DB.
Why both: hook tests with MSW are fast (sub-second) and run on every PR; they catch ~90% of regressions and pin the hook contract. Playwright is slow and brittle but catches real-browser issues (router navigation, SSE connection, focus management) that MSW cannot. The split keeps feedback fast for feature development and adds an "it actually works in a browser" check on CI.
MSW setup: one handler per backend route (mirrors packages/api-client). REST handlers live in apps/web/src/test/msw/handlers.ts and SSE handlers in apps/web/src/test/msw/sse-handlers.ts. Each test imports a base set and overrides per-case; where MSW's SSE support is limiting, the fallback is a custom EventStreamPort mock injected through <PortsProvider />.
10.4 End-to-End Tests (Playwright)
The suite has 12 spec files under apps/web/e2e/tests/ today: dashboard, jobs-drawer, jobs-bulk, dry-run, materials, profile-edit, wizard, runs, settings, plus route-visual-qa, token-foundation, and docs-screenshots (the last drives the screenshots embedded in the docs). Representative critical flows:
- Dashboard load → KPIs render → click a KPI → navigate to filtered jobs view → row count matches.
- Job detail drawer → click a row → drawer opens with score, stages, artifacts → close → drawer closes; URL preserves the filter.
- Soft-delete + restore → bulk-select 3 jobs → delete → confirm removal from active list → switch to "deleted" tab → restore → confirm re-appearance.
- Profile edit + Plate baseline editor → load profile → edit a field → save → baseline resume HTML is refetched with a new cache key and remains rendered in the Profile Plate editor.
- Resume import wizard → upload a PDF → preview parsed draft → confirm → wizard exits to profile editor; profile reflects imported sections.
- Generate materials → click "Generate" on a job → drawer shows "queued" → simulate
ResumeApprovedevent in the seed → drawer shows approved status. - Dry-run apply → click "Dry run" → apply-run drawer opens with live timeline → simulated
DryRunCompleteevent closes the run. - Settings update → change a setting → confirm persistence.
Playwright is configured with per-test isolated SQLite databases seeded from fixture files; the apps/api boots against the test DB; the test interacts with the rendered web app at http://127.0.0.1:5173.
10.5 Storybook (Component-Driven Development)
~88 colocated *.stories.tsx today. Stories serve three audiences:
- Developers — visual playground while building.
- Designers — review surface without booting the full app.
- Accessibility + interaction — the Storybook test runner (
pnpm web:storybook:test) drives the a11y addon (zero critical/serious axe violations, §10.7) and play functions.
Stories live next to components (<Component>.stories.tsx). Domain-component stories use the MSW addon to mock API responses, so a story for <JobsTable /> can show loading, populated, and empty states without booting the real backend. Snapshot-based visual regression (Chromatic or open-source Loki) is a named-not-built addition on top of the existing stories.
10.6 Type-Level Tests
Beyond the workspace typecheck, the frontend runs 9 *.test-d.ts files under apps/web/test/types via Vitest's typecheck mode (separate config vitest.types.config.ts, invoked by pnpm --filter @jobctrl/web test-d). There is no tsd dependency; assertions use Vitest expectTypeOf.
- Type assertions on hook return shapes catch accidental widening of the inferred types. (E.g., assert
useJobsListQuery(...)returnsUseQueryResult<PaginatedResponse<JobSummary>>, notUseQueryResult<unknown>.) - Typed search-param tests assert the inferred type of
useSearch({ from: "/jobs" })matches the Zod-derived type.
10.7 Accessibility Spot Checks
Accessibility is enforced on two surfaces. Colocated *.a11y.test.tsx files (12 today) run axe against components with user input (forms, dialogs, tables). Storybook's a11y addon enforces zero critical and serious axe violations across stories; a story that exercises a pre-existing production defect may set parameters.a11y.test = "off", but only with a matching entry in the "Frontend Accessibility Backlog" in docs/backlog.md (10 such deferrals are recorded there today, matching the 10 stories in code).
10.8 What We Do NOT Test
- shadcn/ui primitive internals — those are upstream-tested.
- TanStack library internals — same.
- Visual pixel-perfectness beyond Storybook snapshots.
- Performance — bundle-size and runtime perf budgets are CI gates, not Vitest tests.
10.9 CI Pipeline (Cross-Reference)
The GitHub Actions TypeScript workflow (.github/workflows/typescript.yml) runs, in order:
pnpm -r check(workspace typecheck, includingapps/web). The compile-time guards (Record<DomainEventType, InvalidationHandler>exhaustiveness, etc.) fire here — this is the CI-enforced parity guard.pnpm --filter @jobctrl/api test(the API Vitest suite; API only).pnpm --filter @jobctrl/web build(Vite production build).pnpm --filter @jobctrl/web storybook:build(static Storybook build).pnpm --filter @jobctrl/web storybook:test(Storybook test runner — play functions +@storybook/addon-a11yaxe checks — after installing Playwright Chromium).
Not yet gated in CI (run locally / pre-merge; tracked in docs/backlog.md): the web Vitest unit + integration suite — including the event-handler and stage-state parity tests (§10.2) — ESLint, and the Playwright e2e suite (§10.4). Chromatic / Loki visual regression is named-not-built (§10.5). The frontend's parity tests are the analogue of the backend's scripts/check-domain-type-parity.py; today their CI-enforced half is the pnpm -r check typecheck, with the runtime backstop running locally.