Skip to content

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.ts before any handler (ADR-0010, ADR-0013); only a test through the real server sees them
  • bun test — built into the runtime; no transform pipeline to configure; --parallel runs 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

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.

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.

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.

Pros: fastest. Cons: SQL, constraints, migrations, and transactions are never tested — where most data bugs live.

  • Playwright (@playwright/test 1.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

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.toml sets [test] root = "src", so Playwright specs in e2e/ are never collected

  • Three scripts:

    Script Command Files For
    test bun test --parallel --path-ignore-patterns '**/*.int.test.ts' *.test.ts, *.test.tsx Pure logic, Zod schemas, components
    test:int bun test/int.ts *.int.test.ts Data access, domain services, API handlers
    test:e2e bunx playwright test e2e/*.spec.ts Interactions, 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 pragma bun test honours, 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 fresh QueryClient seeded with setQueryData — 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.ts starts Postgres and Valkey on the same image tags as production, applies the same Drizzle migrations production runs into a template database, then runs bun test --parallel int.test with 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 a Request and the authenticated caller in context (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 Playwright storageState

  • No real third-party calls. External HTTP (OAuth providers, payment and email APIs) is mocked with MSW 3 (setupServer from msw/node), started with onUnhandledFrame: "error" so an unmocked request fails the test

  • E2E runs the production build (bun run build, started by Playwright’s webServer) 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/playwright on 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 POST to a server function is rejected (ADR-0013)
    • a /api/v1/ request with a session cookie and no token gets 401 (ADR-0036)
    • a credential endpoint returns 429 with Retry-After past 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-Ip sent through the edge is ignored (ADR-0017) — this one runs against staging, because it needs Traefik in the path
  • 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.

Easier:

  • One runner for unit, component, and integration tests; no test config beyond bunfig.toml
  • Data bugs (constraints, migrations, transactions, Bun.SQL behavior) 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:int and E2E runs, locally and in CI; bun run test stays 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, now onUnhandledFrame
  • @axe-core/playwright is 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.