Skip to content

Testing

Testing

Tests exist so an agent can close its own loop honestly. A test suite that can’t fail meaningfully provides confidence without coverage, which is worse than no suite at all.

Levels

LevelToolRuns against
UnitVitestPure logic. No I/O
DatabaseVitestReal Postgres — Docker locally, Neon branch in CI
APIVitest + @cloudflare/vitest-pool-workersThe real Workers runtime
WebPlaywrightBuilt Astro output
ContracttscClients against packages/shared

Non-negotiables

Real Postgres, never mocked. pg_trgm and FTS cannot be meaningfully faked, and a mocked query proves nothing about the SQL. Per ADR-0011: Docker locally, Neon branch in CI.

Real Workers runtime for the API. @cloudflare/vitest-pool-workers, not Node with mocked bindings. The runtimes differ in bindings, request lifecycle and available globals — the exact places this project’s traps live.

Write the test first. Step 3 of the loop commits the failing test. A test written after the implementation asserts what the code does; one written before asserts what it should do.

What must have a test

  • Every Definition of Done line that is behavioural
  • Every domain invariant from ADR-0006 and ADR-0007
  • Every bug fix — the regression test comes first, and it must fail before the fix
  • Every migration — that it applies, and that it is reversible or explicitly marked one-way

The invariant tests

These encode the decisions most likely to be broken by a well-meaning change. They live in packages/db and must never be weakened:

  1. A second session rating updates the shelf projection and leaves the first session’s rating intact.
  2. A session participant with a null user_id round-trips through every participant query.
  3. A session insert with only game_id and played_at succeeds.
  4. No table foreign-keys to clerk_user_id — schema assertion, per ADR-0005.

What not to test

  • Framework behaviour. Drizzle, Hono and Zod have their own suites.
  • Getters, pass-throughs, and generated code.
  • Implementation detail. A test asserting how something works blocks the refactor it should have survived.

Test data

Fixtures live beside the tests. Database tests get a transaction rolled back at teardown — no shared mutable state between tests, no ordering dependence.

Catalogue fixtures come from packages/catalogue, so the same data seeds tests and local development.

Anti-patterns

Each of these turns a red gate green while removing the coverage:

  • Deleting or loosening an assertion to pass
  • it.skip without a linked issue
  • Widening a type to silence the typechecker
  • Asserting toBeDefined() where the actual value matters
  • Snapshot tests over structures nobody reads — a diff nobody understands gets accepted blind

If a check is genuinely wrong, that is a work item with an ADR, not an inline edit.

An index cannot be tested by behaviour

Dropping an index does not change any result — only how long it takes. A test that searches for a typo and finds the game passes with the trigram index dropped, which was seeded in WI-021 and detected nothing.

Assert the plan instead, with sequential scans disabled for the statement:

await db.transaction(async (tx) => {
await tx.execute(sql`set local enable_seqscan = off`);
const plan = await tx.execute(sql`explain (costs off) select 1 from games where title % 'x'`);
expect(JSON.stringify(plan)).toContain("games_title_trgm_idx");
});

This deliberately does not assert the index is chosen. On a small table the planner correctly prefers a sequential scan, and asserting otherwise tests the optimiser rather than our schema. Disabling seqscan proves the index exists and applies to the operator — a missing or wrongly-typed index still falls back to Seq Scan even with it off.