Testing
The repo has 570+ tests. They run in CI on every PR and are expected to be green before a branch merges to main.
Test tooling
- Unit / integration: Vitest with
happy-dom. Config implicit fromvite.config.ts+vitestdev dependency. - React Testing Library +
@testing-library/user-eventfor component interaction tests. - MSW (
msw) for mocking HTTP at the network layer (used sparingly — the Supabase mock is the common path). - End-to-end: Playwright (Chromium only in CI). Separate job in
.github/workflows/ci.yml. - Typecheck: TypeScript strict mode. Run with
npx tsc --noEmit.
Running tests
# run the whole suite once (what CI runs)
npm run test:run
# watch mode — re-runs on file changes
npm test
# coverage report
npm run test:coverage
# typecheck
npx tsc --noEmit
# playwright
npm run e2e
npm run e2e:ui # interactive mode
npm run e2e:report # view the last HTML report
Running a single test
Vitest accepts a filename substring and a -t test-name filter:
# run all tests in one file
npm test src/pages/Bookings/Bookings.test.tsx
# run only matching test names
npm test -- -t "rejects double-approve"
# both
npm test src/lib/api.test.ts -t "finance-approve"
For Playwright:
npx playwright test tests/e2e/booking-flow.spec.ts
npx playwright test -g "customer can pay via wallet"
Where tests live
Tests are colocated next to the code they exercise:
src/pages/<Feature>/<Feature>.test.tsx— page-level testssrc/services/<service>.test.ts— service-layer testssrc/lib/api.test.ts(+ topic-specific siblings) — backend handler testssrc/components/**/<Component>.test.tsx— component testssupabase/functions/<function>/index.test.ts— Edge Function tests (Deno tests, run under Vitest via a shim or directly — )
Shared test infrastructure lives under src/test/:
| File | Purpose |
|---|---|
src/test/harness/mockSupabase.ts |
In-memory Supabase client used by financial invariant tests |
src/test/permissions.matrix.test.ts |
Drift detection between docs/PERMISSIONS.md and code references |
(other harness files in src/test/harness/) |
Fixtures, helpers shared across test files |
The Supabase mock
src/test/harness/mockSupabase.ts provides createMockSupabase() returning { client, capture, seed, reset }. It lets api.ts handlers run end to end without a real Postgres — so tests can assert on the actual journal entries, ledger rows, and invoice rows the handler emits.
Key idea: real business logic runs, only the data layer is mocked. Use it whenever you are testing a money-touching handler (finance approvals, wallet credits, voucher flows).
import { createMockSupabase } from '@/test/harness/mockSupabase';
const { client, capture, seed } = createMockSupabase();
seed('bookings', [{ id: 'bk_1', status: 'pending', total: 10000 }]);
// ... invoke the handler with `client` as its supabase ...
expect(capture.inserts('journal_entries')).toHaveLength(1);
expect(capture.inserts('journal_entries')[0]).toMatchObject({
debit_account: 'cash', amount: 10000,
});
Warning
The mock only implements the Supabase methods api.ts actually calls. If you write a handler that uses a new builder method (e.g. .textSearch()), you will have to extend the mock before the test can run. Keep additions minimal and documented inline.
The permissions matrix test
src/test/permissions.matrix.test.ts parses docs/PERMISSIONS.md for the canonical permission catalog and diffs it against every 'x.y' permission string referenced in code (primarily src/lib/api.ts and src/App.tsx).
It fails the build when:
- Code uses a permission name that isn't declared in
PERMISSIONS.md, or PERMISSIONS.mddeclares a permission that no code ever references.
Allowed exceptions live in ALLOWED_DOC_ONLY inside the test file — usually aliases kept for RLS policy compatibility, or permissions that have been seeded but not yet wired into UI. Every entry there is tech debt; don't add more without a tracking reason in the comment.
Tip
When this test fails, the error message tells you exactly which permission drifted and where. Fix either by updating docs/PERMISSIONS.md §4 or by wiring the permission into <ProtectedRoute requiredPermissions=[...]>, <PermissionGate permission="...">, or await requirePermission('...').
Coverage expectations
From CLAUDE.md: when you add a booking / finance / partner flow, write tests in the corresponding src/pages/**/*.test.tsx or src/services/*.test.ts file.
Required at minimum:
- Happy path — the flow succeeds with valid input.
- Permission gate — a user without the required permission is rejected (404 on frontend, 403 on backend).
- Idempotency — if the flow is money-touching (approvals, payments, voucher creation), a double-submit must not create duplicate rows. See the
ops-approve/finance-approvehandlers for the pattern to assert against. - Validation — zod schema rejections return structured errors, not 500s.
For financial handlers specifically: assert on the shape of the journal entries the handler emits via the mock Supabase capture. The ledger invariant matters more than the HTTP status code.
What to do when a test fails in CI but passes locally
- Check Node version — CI uses Node 24. Run
node -vlocally. Switch withnvm use 24orfnm use 24. - Delete local caches:
rm -rf node_modules && npm ci. - Check timezone / locale — tests that render dates can differ by TZ. Tests should use
date-fnswith UTC or explicit timezones; if you find a TZ-dependent assertion, fix it. - Flaky test? Re-run the CI job. If it still fails intermittently, mark it with
.todoand open an issue rather than papering over with retries.