Skip to content

Testing

Doable uses Vitest across every package and service. One command at the repo root runs the lot.

Quickstart

pnpm test          # all packages, in parallel via Turbo
pnpm test --watch  # interactive
pnpm type-check    # tsc --noEmit, no tests, fast
pnpm lint          # ESLint

CI runs pnpm install --frozen-lockfile && pnpm type-check && pnpm lint && pnpm test.

Where tests live

  • Unit tests are co-located: foo.test.ts next to foo.ts.
  • Integration / route tests for the API live under services/api/src/routes/__tests__/.
  • WS protocol tests under services/ws/src/__tests__/.
  • DB migration sanity checks under packages/db/src/__tests__/.
  • E2E (rare; mostly happens in doablechore/) — Playwright scripts.

Conventions

  • One describe per public function or HTTP route.
  • Use it.each for table-driven tests.
  • Mock at the boundary (network, filesystem, time). Avoid mocking your own modules — refactor instead.
  • Time: vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01')).
  • Snapshots: only for stable, human-meaningful outputs (rendered Markdown, JSON-Schema). Avoid for HTML.

Database tests

Two flavours:

  1. Pure SQL helpers — start a throwaway Postgres container in a global setup file (vitest.setup.ts), apply migrations, hand each test a transaction that's rolled back at the end.
  2. Logic that doesn't touch the DB — pass the query helpers as DI and provide a fake. Cleaner and faster.

Pattern 1 example:

import { startTestDb, withTransaction } from '@doable/db/test';

let db: Awaited<ReturnType<typeof startTestDb>>;
beforeAll(async () => { db = await startTestDb(); });
afterAll(async () => { await db.stop(); });

it('inserts a project', async () => {
  await withTransaction(db.sql, async (tx) => {
    const id = await createProject(tx, { name: 'demo', ownerId: 'u1' });
    expect(await getProject(tx, id)).toMatchObject({ name: 'demo' });
  });
});

API route tests

Hono lets you app.request('/path', { method: 'POST', body }) synchronously — no need to bind a port:

import { app } from '../../app.js';

it('GET /projects requires auth', async () => {
  const res = await app.request('/projects');
  expect(res.status).toBe(401);
});

it('GET /projects returns the user list', async () => {
  const token = signTestJwt({ sub: 'u1' });
  const res = await app.request('/projects', {
    headers: { authorization: `Bearer ${token}` },
  });
  expect(res.status).toBe(200);
  const body = await res.json();
  expect(body.projects).toBeInstanceOf(Array);
});

AI / Copilot tests

Don't hit a real LLM in CI. Use:

  • A fixture stream of recorded SSE events (fixtures/anthropic-hello.sse) replayed via a fake fetch.
  • The MockProvider shipped in packages/docore/src/__test_helpers__/mock-provider.ts — accepts a script of events and yields them in order.

For end-to-end engine tests, DoCoreEngine accepts an injected provider:

const engine = new DoCoreEngine({ provider: new MockProvider(events), ... });
const stream = engine.run({ prompt: 'hi' });
const out = await collect(stream);
expect(out.map(e => e.kind)).toEqual([
  'assistant.message_delta',
  'assistant.message',
  'done',
]);

Sandboxing tests

  • dovault ships its own suite that runs each backend (Direct, Systemd, JobObject, WindowsHeap) gated by platform. CI runs the appropriate ones for each runner OS.
  • For local development on Linux, the Direct backend is enough.

Frontend tests

apps/web uses Vitest + Testing Library. Component tests focus on behaviour, not pixel layout.

import { render, screen } from '@testing-library/react';
import { ProjectCard } from './project-card';

it('shows the project name', () => {
  render(<ProjectCard project={{ id: '1', name: 'demo' }} />);
  expect(screen.getByText('demo')).toBeInTheDocument();
});

For visual regressions, use the Playwright tests in doablechore/ — they run against a deployed staging instance, not in PR CI.

Coverage

pnpm test -- --coverage

Outputs to coverage/ per package. We don't enforce a threshold in CI yet, but PRs that significantly drop coverage will get review comments.

Debugging a failing test

# In the package's directory:
pnpm vitest run path/to/file.test.ts -t 'name of the it block'
pnpm vitest --inspect-brk    # attach Chrome devtools / VS Code

VS Code: install the Vitest extension, then click "Debug" next to any it.

Test-data hygiene

  • Use random UUIDs in test data; never hard-code secrets.
  • Rolled-back transactions are preferred over manual cleanup.
  • Don't share state across tests within a file unless it's a global expensive setup.

What not to test

  • Auto-generated types.
  • Passthrough wrappers (one-line functions that just call through).
  • Implementation details that change frequently — test the public contract.