Writing tests
Every behaviour change comes with a test. This page says where it goes.
| The change | The test |
|---|---|
| A new or changed capability tool | A describe in api/src/capabilities/capabilities.test.ts (or tools.test.ts for shared tools) |
| A new capability | The tool test above; the registry tests check the rest |
| The Claim shape | packages/shared/src/claim.test.ts, plus whatever the type errors lead to |
| Prompt assembly | api/src/chat/system.test.ts |
| The turn loop or events | api/src/chat/service.test.ts with scriptedModel |
| A store method | The adapter's test, against createTestD1(), and the Neon SQL against the mocked driver |
| An API route | api/test/app.test.ts, including its failure modes (validation, key, CORS) |
| A hook, formatter or store in the browser | A Vitest test beside it |
| What a user sees and does across pages | A Playwright spec |
| Something the scripted model cannot do | Extend api/src/ai/scripted.ts, not a mock around it |
Patterns
Build only what the test is about
const claim = claimWith({
payments: [payment({ id: 'P1', payee: 'Dry Co', amount: 1200 }), payment({ id: 'P2', payee: 'Dry Co', amount: 1200 })],
});
claimWith starts from a complete fixture; the record factories fill every field you do not name. A reader sees only what matters.
Pin the date
Pass today to toolContext(claim, '2026-09-15'). Where code reads the clock at import (TODAY in frontend/src/lib/format.ts), set fake timers and import the module afresh:
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-15T12:00:00Z'));
vi.resetModules();
const { deriveClaimView } = await import('./claimView');
Own the fixture's details
If an assertion depends on a field the fixture happens to set (a return-to-work date, a policy period), set it in the test. A test that passes because of a fixture value breaks the day someone edits the fixture.
Assert on what was sent
For anything that crosses the network, capture the request and assert on it, as well as on the screen. Snapshot the request at the moment it is sent: some callers pass live arrays that change afterwards (structuredClone in the mock).
Mock modules, not behaviour, in the browser
vi.mock('../lib/api', async (importOriginal) => {
const real = await importOriginal<typeof import('../lib/api')>();
return { ...real, api: { ...real.api, chat: mocks.chat, feedback: { ...real.api.feedback, send: mocks.send } } };
});
Keep the real module and replace only the network calls, so the rest of the client is still under test.
Watch out for
beforeEachreturning a value. Vitest treats a function returned frombeforeEachas a cleanup hook.beforeEach(() => mock.mockReset())returns the mock, which Vitest then calls. Use braces.- Strict index access. The tests are typechecked with
noUncheckedIndexedAccess. Use[0]!where a test has just asserted the element exists. - Substring locators in Playwright. Use
exact: true.
Before committing
npm run typecheck && npm run test:coverage && npm run test:e2e
Or npm run test:all, which is what CI runs.