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 294 colocated *.test.ts(x) under apps/web/src (124 .test.ts + 170 .test.tsx, of which 37 are colocated *.a11y.test.tsx), 11 type-level *.test-d.ts under apps/web/test/types, 19 Playwright *.spec.ts under apps/web/e2e/tests, and 119 *.stories.tsx Storybook story files.
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).
The boundary runner (corepack pnpm web:lint:test) permits canonical DTO, schema, constant and domain imports without routing exemptions. Its independent negative controls retain API-client, Query/Zustand, browser-port, dependency, barrel and stale-exemption enforcement. It inspects the actual tsconfig/generated route program, with direct loader-access controls for any/unresolved context, casts, aliases, nested destructuring, bracket access and unresolved barrels. Expression fallbacks, destructuring defaults, member assignments, computed capability slots and container/destructured runtime exports have independent controls for routes and views, with resolved and missing package declarations. Array spread/rest, awaited values and forwarding calls have independent source-located controls, including conditional/mutable callees and capability containers. Positive controls retain context-owned helper handoffs and harmless array positions. Loop bindings (including nested destructuring, assignments and for await), comma results/aliases and capability storage in instance, static or private class fields (including constructor/method writes) have separate generated-loader and view controls. Comma-result barrels are checked with both resolved and missing package declarations. Mixed default/named imports (including named type-only bindings), ordinary parameter defaults, constructor parameter properties, capability-bearing method receivers and raw context-helper returns through any have generated-program negative controls. Positive controls preserve harmless defaults, container methods, local shadowing and DTO-returning context helpers. These require boundary diagnostics at actionable source locations independently of compiler diagnostics; compiler-clean any cannot authorize a capability. Generated-route composition tests exercise loader-to-API/cache handoff, tenant separation, warm/cold caches, dependency changes and failed reads/recovery. Hook tests retain enabled/select/refetch policy; shell navigation scenarios cover passive reads, reload/Back and transport failure recovery against the owned synthetic API. Browser execution and product QA remain required checks. Type-level read-hook tests compare inferred results to canonical DTOs; readonly Jobs stage input remains a frontend refinement rather than a duplicate schema.
These tests do not mount React components.
Pure files explicitly select Node with // @vitest-environment node; tests that need browser behavior retain jsdom. Both environments reject unexpected network requests through MSW. DOM setup, React cleanup, and browser polyfills load only for jsdom files. Tests using the shared provider render helper also install the default API handlers and interview fixtures. Teardown unmounts React before resetting handlers and fixture state. The upstream axe assertion remains synchronous and loads its implementation when used.
The boundary regression suite runs two files through Node's test runner with --test-concurrency=2: module ownership/generated-route controls and capability handoff/flow controls. Each process uses independent in-memory fixtures and fresh TypeScript programs; every real-program case still requests semantic diagnostics for the complete graph. The standalone production boundary check remains an independent cold run.
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 detail workspace, 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; each test overrides the base handlers per case. Event-stream tests use FakeEventStreamPort from apps/web/src/test/testPorts.ts, injected through <PortsProvider />.
10.4 End-to-End Tests (Playwright)
Resume-review lifecycle coverage spans the Apply controller, Materials document modules, browser interactions and canonical API persistence. The controller's colocated tests cover tenant/job/source identity, stale cache responses, independent comments, normalized save acknowledgements and approval preparation. The artifact-comparison.spec.ts browser suite preserves later typing and undo through delayed responses, exercises real multiline HTML paste and reload, and requires recovery from save conflicts and failed renders before authorization. Its controlled HTTP responses isolate the session from providers/renderers; canonical artifact bytes and approval/audit preservation are checked separately by the API suites listed in Frontend QA.
The suite has 18 spec files under apps/web/e2e/tests/ today: analytics, artifact-comparison, dashboard, dry-run, evidence-map, interview-prep, jobs-bulk, jobs-drawer, materials, mobile-connection-banner, outreach, profile-edit, runs, settings, token-foundation, wizard, plus route-visual-qa 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 workspace → click a row → the full route workspace opens with score, stages, artifacts, and a back action; returning preserves the URL filter.
- Soft-delete + restore → bulk-select 3 jobs → delete → confirm removal from the Active-filtered list → set the Job state column filter to Deleted → 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 → its detail workspace shows "queued" → simulate
ResumeApprovedevent in the seed → the workspace shows approved status. - Dry-run apply → click "Dry run" → the apply-run route workspace opens with live timeline → simulated
DryRunCompleteevent closes the run. - Settings update → change a setting → confirm persistence.
Each main web Playwright invocation owns a disposable workspace with a seeded SQLite database and its own API and web servers. Tests share that database serially and use fresh browser contexts. By default the web server builds a fresh production bundle inside the workspace and serves it through Vite preview, retaining the same-origin API and SSE proxy. JOBCTRL_E2E_WEB_SERVER=dev selects Vite's development server for small focused runs where a fresh build would dominate. The default API/web ports are 8767/5174; the owning development commands describe isolated runs and overrides.
10.5 Storybook (Component-Driven Development)
The frontend has 112 colocated *.stories.tsx files 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 11 *.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 under apps/web/src/ run axe against input-bearing components and other high-risk surfaces; the current source-derived inventory lives in docs/local-reliability-qa.md. 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" only with a matching GitHub issue recording the story path, affected production file and defect type.
10.8 Primitive Ownership And Non-Goals
- Base UI internals are upstream-tested, but the copied shadcn/Rhea wrappers are JobCtrl-owned code and are not exempt. Focused wrapper tests cover the public interaction, keyboard, accessibility, styling, and composition contracts used by the product.
- Primitive-boundary drift is covered by
shared/ui/base-ui-migration-boundary.test.ts: no direct@radix-ui/*imports, no raw<select>bypasses, and the Base UI portal root keeps its isolated stacking context. The empty Radix allowlist is intentional. - 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) has independent API, web, Storybook and E2E jobs. After locked dependency setup:
- The API job checks shared contracts, API client, domain types and API, then tests domain types and API behavior with the locked worker available.
- The web job runs
corepack pnpm web:lintandweb:lint:test, thenweb:check,web:test,web:test-dandweb:buildin that order. Compile-time guards such asRecord<DomainEventType, InvalidationHandler>enforce exhaustiveness here. - The Storybook job builds the stories, installs Chromium and runs
web:storybook:testfor play functions and axe checks. - The E2E job installs Node and locked Python Chromium runtimes, then runs
web:e2eagainst the isolated fixture API and the selected real API/worker RPC scenarios in a separate owned workspace.
Dependency-boundary enforcement from #895 is delivered through corepack pnpm web:lint and its boundary regression suite, and runs in the TypeScript CI web job. 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; both the typecheck and the web Vitest runtime backstop run in CI.
Saved posting availability coverage
The availability component/mutation tests cover latest uncertainty alongside prior successful evidence, overdue/offline explanation, port dispatch, queued feedback, tenant-scoped invalidation, optimistic rollback and accessibility. Registry-derived event fixtures cover observation and intentional internal lease no-op handling. Product QA additionally drives the real worker/API/CLI and rendered Job Detail through active, unknown and closed-to-active synthetic transport responses; builds or component tests alone do not establish that workflow path.