Skip to content
BoringStack
GitHub

Testing

4 min read

UI Test Strategy

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.

3

test layers

No MSW

no mock drift

Chromium + WebKit

e2e browsers

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.

Layered

Three layers, not one

Unit tests cannot catch routing bugs; e2e cannot catch every hook edge case.

No drift

No MSW

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

Real stack

e2e against ./dev.sh

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 committed per OS.

Coverage excludes *.stories.tsx, *.types.ts, *.constants.ts

Numbers only count files you can meaningfully test.

Unit / component

Location: src/**/*.test.ts colocated with source. Run: bun run test.

e2e

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.