Skip to Content
Living documentation — last reviewed 2026-05-28
TestingTesting strategy

Testing strategy

Three test types, three commands, two databases, one driver pattern. CI runs the fast gate on every PR and the full gate on every main push.

The pyramid

TypeWhereRuntimeWhat it covers
Unit (*.unit.spec.ts)next to sourcevitest, ms per testPure functions, services with no side effects, utils
Integration (*.int.spec.tsx)next to source / under a featurevitest + Testing Library, ~10ms–100ms per testUI components rendered with providers + mocked API; service-level multi-collaborator behaviour
E2E (apps/web/e2e/specs/*.spec.ts)Playwrightsec per testFull browser → real API → real Postgres + Redis

The goal: most coverage in unit, behaviour in integration, the user journeys in E2E.

Commands

The canonical entry points are Makefile targets (see make help).

make test-unit-api # API unit tests make test-integration-api # API integration tests (need test DB up) make test-e2e-api # API E2E tests (test DB + redis) make test-unit-web # Web unit tests make test-integration-web # Web integration tests (E2E_TEST_MODE on) make test-e2e-web # Playwright — boots both api + web make test-local-smoke # All non-Playwright tests make test-local-all # Smoke + Playwright make update-screenshots # Update Playwright visual baselines

Infrastructure helpers:

make test-db-up # Postgres on 55432, Redis on 56379 make test-db-down # Tear down make test-db-migrate # Run drizzle migrations against the test DB

The test stack runs on docker-compose.test.yml with ports offset (55432 / 56379) so it doesn’t collide with make dev-up (5432 / 6379). You can keep both running simultaneously.

Databases

StackPostgres portRedis portCompose file
Dev54326379docker-compose.yml
Test5543256379docker-compose.test.yml

The Makefile’s EXPORT_API_ENV macro injects safe defaults (test DB URL, dummy encryption keys, dummy R2 creds) so individual targets don’t need an .env.test to function.

The driver pattern

Every UI/component test uses a co-located driver (*.driver.tsx / *.driver.ts) that encapsulates every screen.* call. Specs never import screen directly. The driver exposes a given / render / get / has / find / click API.

See driver-pattern.md for the full contract and examples.

Why: when a class name, role, or test id changes, you fix it in one place. Specs stay business-readable.

Mocking rules

  • vi.mock() calls live in spec files, not drivers. Vitest hoists per-file; mocks declared in drivers don’t apply where they’re imported.
  • vi.advanceTimersByTime() wraps in act() to avoid React warnings.
  • No real Clerk calls in unit/integration — Clerk hooks are mocked via the helpers in apps/web/src/test-utils/.
  • No real API calls in unit/integration — useApi and serverFetch are mocked per-test.

Selectors — hard rule

Use data-testid only for element selection. Never getByText, queryByText, CSS classes, tag names, or structural selectors.

Why: translated strings (en/he/ru) make text-based selectors flaky; structural selectors break on layout refactors.

Exceptions are narrow — getByRole is acceptable for accessibility-affirming assertions (e.g. asserting a button has the right name) and is used in some E2E drivers, but the default is testid.

Async

  • Use waitFor and findBy* for anything async.
  • No setTimeout-based waits; no arbitrary await new Promise(...) sleeps.
  • Use vi.useFakeTimers() + vi.advanceTimersByTime() inside act() when testing time-based behaviour.

Radix overlays

Every Drawer | Sheet | Dialog | AlertDialog must include a visually hidden description component (DrawerDescription, SheetDescription, DialogDescription, AlertDialogDescription). Required for screen-reader compliance — Radix warns in console otherwise, and tests assert no React/accessibility warnings.

E2E specifics (Playwright)

Auth caching

apps/web/e2e/global.setup.ts runs a single real Clerk sign-in per CI run using E2E_CLERK_USER_EMAIL / E2E_CLERK_USER_PASSWORD. Result is stored at e2e/.auth/signed-in-state.json via Playwright’s storageState. Specs inherit it through fixtures.

Persona swapping

Specs use a usePersona(role) fixture to flip identity. Mechanism: the x-test-user-id header is set per-request (no Clerk re-sign-in). The API’s AuthGuard test-bypass branch (only on when TEST_AUTH_BYPASS=true) honors the header. The web middleware skips auth.protect() when NEXT_PUBLIC_E2E_TEST_MODE=true.

Data setup

Specs use testApi.seedX() calls that hit /testing/* endpoints exposed by TestingModule (apps/api/src/testing/). The module is only mounted when NODE_ENV !== 'production' && TEST_HOOKS_ENABLED === 'true'. Endpoints seed memberships, plans, sessions, etc., with idempotent SQL.

Drivers (E2E)

E2E drivers live in apps/web/e2e/drivers/, one per feature: onboarding-driver.ts, coaching-grid-driver.ts, purchase-driver.ts, etc.

Pattern (see driver-pattern.md):

  • class FeatureDriver { constructor(private page: Page) {} }
  • given.opened() — navigate + wait for the surface to appear.
  • when.someAction() — user action, returns this for chaining.
  • get.someLocator() — returns a Playwright Locator (assertions live in the spec).

Constants — routes, test ids — come from apps/web/e2e/constants.ts.

What to write when

You’re building…Write…
A pure function / utilUnit test next to the file
A service with mockable collaboratorsUnit test
A React component with internal logicIntegration test using a driver
A page that wires API → UIIntegration test with mocked API
A user journey across multiple pagesE2E spec
A bug fixA failing unit/integration/E2E first, then the fix

When in doubt, write the lower-cost test (unit > integration > E2E).

CI gates

Smoke (.github/workflows/ci-smoke-tests.yml)

Triggers on PRs touching apps/api, apps/web, libs/**, root config files.

Provisions:

  • Postgres + Redis service containers (Postgres on 55432).
  • Sets TEST_AUTH_BYPASS=true, TEST_HOOKS_ENABLED=true, NEXT_PUBLIC_E2E_TEST_MODE=true.
  • Dummy encryption keys.

Runs: drizzle migrations, then unit + integration suites for API + web. No Playwright — that’s the full gate’s job.

Timeout: 45 minutes.

Full (.github/workflows/cd-full-test-gate.yml)

Triggers on push to main and on workflow_dispatch.

Same infrastructure + Playwright E2E. Timeout: 75 minutes. Requires E2E_CLERK_USER_* secrets for the cached sign-in.

PR preview deploy

.github/workflows/deploy-pr-preview.yml deploys the API to a shared Railway “preview” environment whenever Vercel finishes its preview build. Not a test gate per se, but used for manual smoke-testing of in-progress branches.

Coverage

Vitest’s --coverage flag runs by default on test-unit-api, test-unit-web, test-integration-web. Reports are emitted to coverage/ per app. No enforced minimum threshold today.

Common gotchas

  • Mock placementvi.mock() in a driver does nothing. Put it in the spec.
  • Timer + actvi.advanceTimersByTime outside act() produces noisy React warnings that fail strict tests.
  • Stale storage state — when clerk auth changes upstream, delete apps/web/e2e/.auth/signed-in-state.json to force a fresh sign-in on next E2E run.
  • Forgetting TEST_HOOKS_ENABLED — without it, /testing/* endpoints 404, and testApi.seedX() calls fail.
  • Cron handlers in tests — billing-retry tests call cron handlers directly via /testing/*; they require CRONS_ENABLED=true to do real work.
  • driver-pattern.md — the canonical driver shape.
  • qa-contractor-onboarding.md — how external QA picks up per-feature test plans.