Skip to content

10 · Testing

A static SPA has no server to catch a bad deploy — the browser is the only runtime, and the user is the one who finds the bug. Tests are the safety net. The house stack is Vitest for unit/component tests, Testing Library for behaviour-level component tests, and Playwright for end-to-end runs against the real built app. Every gate runs in CI on every PR and blocks merge.

Rules

R-TEST-1 · Vitest is the unit/component runner

Rule. The test runner is Vitest, sharing the Vite config and plugin pipeline. A Vite app does not add Jest.

Why. Vitest resolves the same vite.config.ts (aliases, base, import.meta.env, the React plugin, Tailwind), so tests transform code the way the bundle does. Jest runs a second, divergent toolchain (its own transformer, module resolver, ESM quirks) — the classic "passes in Jest, breaks in the build" gap, plus duplicated config to keep in sync.

// package.json (web) — the target
"devDependencies": {
  "vitest": "^3",
  "@testing-library/react": "^16",
  "@testing-library/jest-dom": "^6",
  "jsdom": "^25"
}

vcqa. vitest present; jest/babel-jest/ts-jest absent; a test script invokes vitest; Vitest config reuses the Vite config (root vite.config.ts with a test block, or vitest.config.ts that imports it).

R-TEST-2 · Test behaviour through Testing Library, not implementation

Rule. Component tests use @testing-library/react and query by accessible role, label, or text (getByRole, getByLabelText, findByText) — not by test-id-everywhere, CSS class, component internals, or state. No shallow rendering, no enzyme.

Why. A static SPA's only contract with the user is the rendered, accessible DOM. Tests pinned to internals (a hook's state, a private method, a class name) break on every refactor while missing real regressions; role/label queries assert what the user actually perceives and double as an accessibility check (R-TEST-8). Shallow rendering asserts a component tree that never runs the way it ships.

// ✅ behaviour: query the accessible DOM, interact like a user
render(<LoginForm />);
await userEvent.type(screen.getByLabelText(/email/i), 'a@b.co');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/invalid/i);

// ❌ implementation: reaches into internals, asserts nothing the user sees
expect(wrapper.find('LoginForm').state('error')).toBe(true);

vcqa. @testing-library/react present; enzyme/react-test-renderer shallow usage absent; component tests favour getByRole/getByLabelText/getByText over getByTestId and over container.querySelector.

R-TEST-3 · Mock the network at the boundary with MSW

Rule. Tests that hit an external API or platform SDK intercept at the network boundary with MSW (Mock Service Worker) — they do not monkey-patch fetch, stub the data-fetching hook, or replace the API client module.

Why. Data in this archetype comes from outside the app (R-DATA-*). Mocking at the transport layer exercises the real request-building, serialization, error-mapping, and caching code; stubbing an internal module skips exactly the code most likely to be wrong and rots when the internals move. One MSW handler set also serves both Vitest and Playwright.

// ✅ intercept the request itself
server.use(
  http.get('/api/todos', () => HttpResponse.json([{ id: '1', title: 'x' }])),
);

// ❌ stubbing the app's own module — the real fetch code never runs
vi.mock('../api/client', () => ({ getTodos: () => Promise.resolve([]) }));

vcqa. msw present; test setup starts a setupServer/worker; vi.mock of the app's own API/fetch modules and hand-rolled global.fetch = vi.fn() are the exception, not the pattern.

R-TEST-4 · Test loading and error states, not just the happy path

Rule. For every async view, tests assert the loading, success, and error (and where relevant empty) states — driven by MSW responses, including a failing/500 and a delayed response.

Why. In a serverless SPA every network call can fail in the user's browser (offline, CORS, 5xx, expired token) and there is no server-rendered fallback to hide behind. Untested error and loading branches are where SPAs show a blank screen or a spinner that never resolves.

vcqa. Component/integration tests for async views include at least one error-path case (MSW handler returning an error) and assert a visible loading indicator; not solely happy-path 200 assertions.

R-TEST-5 · Playwright drives the real built app end-to-end

Rule. End-to-end tests live in web/e2e with a playwright.config.ts, and run against the production build served by vite preview (or the built dist/), not the dev server.

Why. Dev-server behaviour diverges from the shipped bundle: minification, asset hashing, the relative base (R-BUILD-2), code-splitting boundaries, and env inlining only exist in the build. E2E against vite preview is the closest thing to what the user loads; it's the gate that catches "works in dev, blank in prod".

// playwright.config.ts — build, then serve the real output
export default defineConfig({
  testDir: './e2e',
  webServer: {
    command: 'pnpm build && pnpm preview --port 4173',
    url: 'http://localhost:4173',
    reuseExistingServer: !process.env.CI,
  },
});

vcqa. @playwright/test present; playwright.config.ts exists with testDir under web/e2e; webServer.command runs build+preview (not dev).

Rule. At least one Playwright test navigates directly to a non-root route (e.g. page.goto('/settings/profile')) and one reloads on a deep route, asserting the app renders that view rather than a 404.

Why. This guards the SPA-fallback contract (R-BUILD-3) end-to-end. Client-side routing makes deep links work only if the host serves index.html for unknown paths; a click-through-only e2e suite passes even when a direct hit or refresh 404s in production. This is the single most common SPA hosting regression and needs an explicit test.

// ✅ direct hit + reload must render the route, not 404
await page.goto('/settings/profile');
await expect(page.getByRole('heading', { name: /profile/i })).toBeVisible();
await page.reload();
await expect(page.getByRole('heading', { name: /profile/i })).toBeVisible();

vcqa. An e2e spec calls page.goto on a non-root path and/or page.reload() on a deep route and asserts real content; not exclusively root-then-click navigation.

R-TEST-7 · Offline / failed-API states are exercised end-to-end

Rule. Playwright covers at least one degraded-network path — an aborted or 500 API response (via page.route(...)) and/or context.setOffline(true) — asserting the app shows a recoverable error UI, not a crash or infinite spinner.

Why. A static client cannot assume its data provider is reachable; the browser is hostile and networks fail. E2E is where you prove the real error boundary and retry UI actually render against the real bundle, not just in a jsdom unit test.

// ✅ force the API to fail and assert graceful degradation
await page.route('**/api/**', (r) => r.fulfill({ status: 500 }));
await page.goto('/');
await expect(page.getByRole('alert')).toContainText(/try again/i);

vcqa. An e2e spec uses page.route to fail/abort an API call or setOffline(true) and asserts an error/retry UI; the app is not assumed always-online in e2e.

R-TEST-8 · Accessibility assertions are wired into the suite

Rule. Automated a11y checks run in tests — axe in component tests (jest-axe / vitest-axe) and/or @axe-core/playwright in e2e — asserting no violations on key views.

Why. Accessibility (see the A11Y page) regresses silently; a scanner catch in CI is cheaper than a manual audit. Role-based Testing Library queries already lean on the accessibility tree, and an axe pass turns that into an enforced gate on contrast, labels, and ARIA misuse.

// ✅ fail the test on any axe violation
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);

vcqa. jest-axe/vitest-axe or @axe-core/playwright present and invoked with a zero-violations assertion on at least the primary views.

R-TEST-9 · A meaningful coverage floor on logic

Rule. Coverage is collected (vitest --coverage) and a floor is enforced on logic modules (data mapping, validation, hooks, utils) — a realistic threshold (e.g. 80% of that code), not a repo-wide vanity 100%.

Why. Untested transform/validation code is where silent data corruption hides in a client app. A floor keeps coverage from eroding PR by PR; scoping it to logic (excluding generated files, config, pure-presentational leaves) keeps the number honest instead of gamed with shallow render snapshots.

// vitest coverage — enforce, and scope to logic
"coverage": {
  "provider": "v8",
  "thresholds": { "lines": 80, "functions": 80, "branches": 70 },
  "exclude": ["**/*.stories.tsx", "**/main.tsx", "e2e/**"]
}

vcqa. Coverage config present with non-zero thresholds; a coverage step runs in CI; the threshold is not trivially 0 and not a suspicious blanket 100.

R-TEST-10 · The test gate runs in CI and blocks merge

Rule. A GitHub Actions workflow runs, on every PR, typecheck + unit/component + e2e, and merge is blocked on their success (required status checks). Tests are not laptop-only.

Why. No-laptop-deploys (R-BUILD-7) is only real if the same gate that guards deploys guards merges. Typecheck belongs in the gate because tsc catches the whole class of shape bugs a static app otherwise ships to the user (cross-ref R-SETUP-7). A green local run that never runs in CI protects nobody.

# .github/workflows/ci.yml — the gate
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck            # part of the test gate
- run: pnpm test --coverage      # vitest
- run: pnpm exec playwright install --with-deps
- run: pnpm e2e                  # playwright against the build

vcqa. A CI workflow runs typecheck, vitest, and Playwright on pull_request; the repo declares these as required checks; e2e installs browsers and runs against the build.

Checklist

  • Vitest is the runner, sharing Vite config; no Jest (R-TEST-1)
  • Testing Library by role/label; no shallow rendering or internals (R-TEST-2)
  • Network mocked at the boundary with MSW, not internal stubs (R-TEST-3)
  • Loading + success + error states all tested (R-TEST-4)
  • Playwright e2e in web/e2e against the built app (R-TEST-5)
  • Deep-link + refresh on a route covered by e2e (R-TEST-6)
  • Offline / failed-API states exercised end-to-end (R-TEST-7)
  • axe accessibility assertions in component and/or e2e tests (R-TEST-8)
  • Coverage collected with a meaningful floor on logic (R-TEST-9)
  • Typecheck + unit + e2e run in CI and block merge (R-TEST-10)