ADR-0014 Testing
Company standard testing — bun test for unit, component (happy-dom) and integration tests (real Postgres and Valkey in Testcontainers); Playwright for interactions and end-to-end.
- Decision
- bun test + Testcontainers; Playwright E2EShip & run
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Builds on
Cited by
- 0006Runtime & toolchain
- 0008ORM / query layer
- 0015Observability
- 0016CI/CD
- 0017Reverse proxy & TLS
- 0018Tables
- 0019Virtual lists
- 0020Charts
- 0021Markdown
- 0022Syntax highlighting
- 0024Keyboard shortcuts
- 0025AI SDK
- 0026Project structure
- 0027Engineering principles
- 0028Background jobs & scheduling
- 0029Transactional email
- 0030File & object storage
- 0033Authorization
- 0035Runtime & toolchain
- 0036API layer
Context
Section titled “Context”Bun (ADR-0035) ships a test runner: bun test, with a Jest-compatible API from bun:test, mocks, snapshots, coverage, per-file DOM environments and parallel files. What is still open is what each kind of test runs against. (Amended 2026-11-13: this ADR first chose Vitest through Vite+, with component tests in Vitest’s browser mode; ADR-0035 moved the toolchain to Bun.)
The stack makes some things hard to fake:
- Postgres through
Bun.SQL(ADR-0008): an in-process substitute needs a different driver, so the real connection path would go untested - Valkey holds sessions, rate-limit counters and job queues (ADR-0009, ADR-0010, ADR-0028)
- Base UI components (ADR-0011) depend on real focus, pointer events, and layout — popovers, menus, focus traps
- Security behavior lives in middleware: the rate limiter and CSRF check run in
src/start.tsbefore any handler (ADR-0010, ADR-0013); only a test through the real server sees them
Options considered
Section titled “Options considered”Test runner
Section titled “Test runner”bun test— built into the runtime; no transform pipeline to configure;--parallelruns files across cores- Vitest — runs under Bun since 1.4, and its browser mode is the strongest component-test story; but it is a second runner and config next to the one the runtime already has
Component tests
Section titled “Component tests”1. bun test with happy-dom + Testing Library
Section titled “1. bun test with happy-dom + Testing Library”Pros: the same runner as every other test; fast; Bun’s documented setup. Cons: no layout, no real focus or pointer events — Base UI interactions turn flaky or need mocks, so they must be tested elsewhere.
2. Vitest browser mode (Playwright provider)
Section titled “2. Vitest browser mode (Playwright provider)”Pros: real Chromium; Base UI behaves as in production. Cons: a second test runner kept only for components.
Integration test database
Section titled “Integration test database”A. Testcontainers (Postgres + Valkey)
Section titled “A. Testcontainers (Postgres + Valkey)”Pros: the same engines, image tags, migrations, and driver as production; constraints, transactions, and Valkey commands behave for real. testcontainers 12 has modules for both (@testcontainers/postgresql, @testcontainers/valkey), and runs a Bun smoke test in its CI.
Cons: needs Docker locally and in CI; seconds of startup per run.
B. PGlite (Postgres in WASM)
Section titled “B. PGlite (Postgres in WASM)”Pros: no Docker, fast.
Cons: needs drizzle-orm/pglite instead of our driver, so pooling and Bun.SQL are never exercised; pre-1.0 (0.5.10); no Valkey.
C. Mock the data layer
Section titled “C. Mock the data layer”Pros: fastest. Cons: SQL, constraints, migrations, and transactions are never tested — where most data bugs live.
End-to-end
Section titled “End-to-end”- Playwright (
@playwright/test1.64) — Chromium, Firefox, and WebKit; parallel by default; runs on Bun since 1.4 Bun.WebView— built-in headless browser automation, but no test runner, traces, or axe integration- Cypress (16.x) — good DX, but a second browser toolchain next to Playwright
Decision
Section titled “Decision”bun test for unit, component, and integration tests — components in happy-dom, integration tests against real Postgres and Valkey in Testcontainers — and Playwright for interactions that need a real browser and for end-to-end. This is the company standard for all new apps.
Concretely:
-
Imports come from
bun:test. No Vitest, no Jest.bunfig.tomlsets[test] root = "src", so Playwright specs ine2e/are never collected -
Three scripts:
Script Command Files For testbun test --parallel --path-ignore-patterns '**/*.int.test.ts'*.test.ts,*.test.tsxPure logic, Zod schemas, components test:intbun test/int.ts*.int.test.tsData access, domain services, API handlers test:e2ebunx playwright teste2e/*.spec.tsInteractions, journeys, accessibility Tests sit next to the code they test; E2E specs live in
e2e/ -
Component tests run in happy-dom, chosen per file: a component test starts with
// @vitest-environment happy-dom, a pragmabun testhonours, and every other file runs without a DOM. Render with@testing-library/react, act with@testing-library/user-event. Data comes from props or a freshQueryClientseeded withsetQueryData— one client per test, never shared. Component tests never hit the network -
Focus, keyboard, and pointer behavior is a Playwright test. Base UI composites (menus, popovers, dialogs, focus traps) and keyboard shortcuts (ADR-0024) are tested in the browser, against the running app
-
Testcontainers start once per run.
test/int.tsstarts Postgres and Valkey on the same image tags as production, applies the same Drizzle migrations production runs into a template database, then runsbun test --parallel int.testwith the connection details in the environment. Each test file creates its own database from the template (CREATE DATABASE … TEMPLATE …), so files run in parallel without sharing rows, and gives its Valkey client a key prefix made from the file name -
Keep handlers thin so logic is testable. Business rules live in plain modules that take their dependencies (database, clock, external clients) as parameters; server functions and API route handlers only parse, authorize, and call them. Integration tests cover the modules, and call the handlers in
server/api/directly, with aRequestand the authenticated caller incontext(ADR-0036) -
Auth in tests goes through Better Auth, never hand-inserted rows: integration tests create users and sessions with
auth.api; E2E signs in once per worker and reuses PlaywrightstorageState -
No real third-party calls. External HTTP (OAuth providers, payment and email APIs) is mocked with MSW 3 (
setupServerfrommsw/node), started withonUnhandledFrame: "error"so an unmocked request fails the test -
E2E runs the production build (
bun run build, started by Playwright’swebServer) against Postgres and Valkey containers, so the real middleware chain runs. Cover each product’s critical journeys, starting with sign-up and sign-in. Run@axe-core/playwrighton every page a journey visits; serious and critical violations fail the test -
Every app keeps six guard tests for invariants earlier ADRs depend on:
- a cross-site
POSTto a server function is rejected (ADR-0013) - a
/api/v1/request with a session cookie and no token gets401(ADR-0036) - a credential endpoint returns
429withRetry-Afterpast its limit (ADR-0010) - a signed-out request to a private server function is refused (ADR-0013)
- no exported span carries a URL’s query string (ADR-0015)
- a forged
X-Real-Ipsent through the edge is ignored (ADR-0017) — this one runs against staging, because it needs Traefik in the path
- a cross-site
-
Coverage with
bun test --coverage(coverageReporter = "lcov"), reported in CI with no percentage gate — gates reward trivial tests; the guard tests and review decide what must be covered -
CI wiring (ordering, caching, browser install, retries) belongs to the CI/CD ADR (ADR-0016)
The deciding factors: the runner the runtime already ships, tests that hit the same Postgres driver, Valkey, and middleware as production, real-browser coverage where it matters, and guard tests that keep the security decisions of earlier ADRs from silently regressing. We accept needing Docker for integration and E2E runs, and moving interaction tests from component tests to Playwright.
Consequences
Section titled “Consequences”Easier:
- One runner for unit, component, and integration tests; no test config beyond
bunfig.toml - Data bugs (constraints, migrations, transactions,
Bun.SQLbehavior) surface in tests, not production - Unit and component tests are fast and need neither Docker nor a browser
- Removing the CSRF middleware or breaking the limiter fails a test instead of shipping
Harder:
- Docker is required for
test:intand E2E runs, locally and in CI;bun run teststays Docker-free for fast loops - happy-dom has no layout or real focus: a component test that passes proves less, and interaction bugs surface only in the slower Playwright suite. Keep interactive logic thin and covered there
- The happy-dom pragma is named after Vitest; Bun documents it, but it reads oddly
- Playwright’s own project does not support Bun; if an upgrade breaks it, run the E2E job in Playwright’s image
- MSW 3 is new (2026-09); its v2 examples use
onUnhandledRequest, nowonUnhandledFrame @axe-core/playwrightis MPL-2.0 — fine as a dev dependency; don’t vendor its source
Given up: a real browser for component tests, Docker-free integration tests, and a coverage percentage target. Accepted — one runner the runtime already ships, tests on the real driver and middleware, and interactions proven in Playwright, prove more per minute spent. Visual regression testing is out of scope; a product that needs it writes its own ADR.

