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
| Level | Tool | Runs against |
|---|---|---|
| Unit | Vitest | Pure logic. No I/O |
| Database | Vitest | Real Postgres — Docker locally, Neon branch in CI |
| API | Vitest + @cloudflare/vitest-pool-workers | The real Workers runtime |
| Web | Playwright | Built Astro output |
| Contract | tsc | Clients 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:
- A second session rating updates the shelf projection and leaves the first session’s rating intact.
- A session participant with a null
user_idround-trips through every participant query. - A session insert with only
game_idandplayed_atsucceeds. - 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.skipwithout 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.