Skip to main content

Writing tests

Every behaviour change comes with a test. This page says where it goes.

The changeThe test
A new or changed capability toolA describe in api/src/capabilities/capabilities.test.ts (or tools.test.ts for shared tools)
A new capabilityThe tool test above; the registry tests check the rest
The Claim shapepackages/shared/src/claim.test.ts, plus whatever the type errors lead to
Prompt assemblyapi/src/chat/system.test.ts
The turn loop or eventsapi/src/chat/service.test.ts with scriptedModel
A store methodThe adapter's test, against createTestD1(), and the Neon SQL against the mocked driver
An API routeapi/test/app.test.ts, including its failure modes (validation, key, CORS)
A hook, formatter or store in the browserA Vitest test beside it
What a user sees and does across pagesA Playwright spec
Something the scripted model cannot doExtend 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​

  • beforeEach returning a value. Vitest treats a function returned from beforeEach as 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.