Skip to content
BoringStack
Star

Testing

4 min read

Three test layers, each earning its keep: Vitest for pure logic, Testing Library for component rendering, Playwright for end-to-end against the real Compose stack. No mock service workers. Anything HTTP-shaped is e2e.

flowchart LR
  unit["Unit<br/>Vitest<br/>no backend"]
  component["Component<br/>Testing Library<br/>no backend"]
  e2e["e2e<br/>Playwright<br/>real stack"]
  visual["Visual<br/>Playwright<br/>per-platform"]
  unit --> component --> e2e --> visual

Four test layers in order of cost and scope: Vitest units (no backend) feed into Testing Library component tests (no backend), which give way to Playwright end-to-end against the real Compose stack, and finally per-platform Playwright visual snapshots.

Three layers, not one. Unit tests cannot catch routing bugs; e2e cannot catch every hook edge case. Each layer earns its place.

No MSW. Hand-written mocks drift from the real backend; HTTP-shaped tests run e2e instead.

E2e against ./dev.sh. This catches API-shape drift and integration regressions without any extra scaffolding.

Playwright with Chromium + WebKit. Safari behavior bugs surface in CI, not from a user report.

Visual snapshots per-platform. macOS vs Linux font rendering differs; baselines are committed per OS.

**Coverage excludes *.stories.tsx, .types.ts, .constants.ts. Numbers only count files you can meaningfully test.

Unit and component tests. Location: src/**/*.test.ts colocated with source. Run: bun run test.

E2e tests. Location: e2e/*.spec.ts. Run: bun run e2e (needs dev stack up).

Visual baselines. Location: e2e/visual.spec.ts-snapshots/. Run: bun run e2e:visual:update to refresh.

Coverage. Run via bun run test:ci.

Unit and component tests focus on pure logic; anything HTTP-shaped is e2e against the real backend. Three reasons:

  • Drift. Hand-written mocks fall behind the real API shape, and tests pass while the real backend drifts.
  • Mental tax. Contributors would have to learn the mocking framework and the real API.
  • False signal. “Mock tests are green” isn’t “the feature works.” Only the e2e tier proves the feature.

Component test: render the component, assert on the rendered output. Don’t reach into hook internals; hooks have their own test if they’re complex enough to need one.

Hook test: renderHook from Testing Library; assert on the returned view object (the IXxxView shape). Standard pattern for testing a component’s logic without rendering the UI.

E2E test: navigate, interact, assert. Use Playwright’s page-object pattern under e2e/pages/ for anything reused across specs. Baseline visual diffs live in e2e/visual.spec.ts-snapshots/.

Usually one of three things:

  • Visual baselines: different OS font rendering. The CI workflow stores baselines per-platform; run bun run e2e:visual:update on a matching machine, or regenerate baselines in CI itself.
  • Flaky timing: Playwright auto-waits, but custom polling loops in app code can race. Look for setTimeout-based assumptions.
  • API drift: backend changed, bun run generate:api wasn’t run. CI catches this via the schema diff check.

@boring-stack-pkg/eslint-plugin-test-conventions enforces tests/ mirrors src/ and that every test file has a real source file behind it. No orphan tests, no source files without tests for the things that need them.

vitest.config.ts · playwright.config.ts · e2e/ on GitHub.

The shared jsdom setup provides a ResizeObserver lifecycle stub for Radix primitives. It does not calculate sizes or emit resize notifications. Assert size-dependent behaviour in Playwright; do not treat a component mounting under this stub as layout verification.

Use tests/render-with-providers.tsx with explicit translation resources. Each call creates its own QueryClient and i18next instance plus a MemoryRouter. The helper awaits translation initialization and returns the render result, client and i18n instance. Its isolation test verifies cached account data and translated copy cannot leak between renders. This helper does not simulate backend requests.

Run bun run new:feature Posts --i18n-namespace to generate per-locale posts.json files, a page using useNamespace("posts"), and a separate 10 KB gzip namespace budget. Translate the generated English placeholders in each locale before release. The namespace name matches the feature directory so ESLint selects the correct dictionary. Locale parity and unused-key checks cover all namespace files.

English common remains bundled for loading and error messages. Other dictionaries load on demand; failed feature loads reach the error boundary if no fallback is available. Keep the existing initial-route budget rather than raising it to fit translations that belong to a lazy feature. The generator produces starter logic; register the route and implement its behavior and tests before shipping.