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
| Type | Where | Runtime | What it covers |
|---|---|---|---|
Unit (*.unit.spec.ts) | next to source | vitest, ms per test | Pure functions, services with no side effects, utils |
Integration (*.int.spec.tsx) | next to source / under a feature | vitest + Testing Library, ~10ms–100ms per test | UI components rendered with providers + mocked API; service-level multi-collaborator behaviour |
E2E (apps/web/e2e/specs/*.spec.ts) | Playwright | sec per test | Full 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 baselinesInfrastructure 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 DBThe 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
| Stack | Postgres port | Redis port | Compose file |
|---|---|---|---|
| Dev | 5432 | 6379 | docker-compose.yml |
| Test | 55432 | 56379 | docker-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 inact()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 —
useApiandserverFetchare 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
waitForandfindBy*for anything async. - No
setTimeout-based waits; no arbitraryawait new Promise(...)sleeps. - Use
vi.useFakeTimers()+vi.advanceTimersByTime()insideact()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, returnsthisfor chaining.get.someLocator()— returns a PlaywrightLocator(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 / util | Unit test next to the file |
| A service with mockable collaborators | Unit test |
| A React component with internal logic | Integration test using a driver |
| A page that wires API → UI | Integration test with mocked API |
| A user journey across multiple pages | E2E spec |
| A bug fix | A 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 placement —
vi.mock()in a driver does nothing. Put it in the spec. - Timer + act —
vi.advanceTimersByTimeoutsideact()produces noisy React warnings that fail strict tests. - Stale storage state — when
clerkauth changes upstream, deleteapps/web/e2e/.auth/signed-in-state.jsonto force a fresh sign-in on next E2E run. - Forgetting
TEST_HOOKS_ENABLED— without it,/testing/*endpoints 404, andtestApi.seedX()calls fail. - Cron handlers in tests — billing-retry tests call cron handlers directly via
/testing/*; they requireCRONS_ENABLED=trueto do real work.
Where to read next
driver-pattern.md— the canonical driver shape.qa-contractor-onboarding.md— how external QA picks up per-feature test plans.