Writing tests¶
Conventions for adding tests here, and the traps that have already cost real time. The second half is the more valuable part: each one is a case where the suite was green and wrong.
Conventions¶
- Colocate.
foo.ts(x)→foo.test.ts(x), next to the source, in all three packages — no__tests__ortests/directories. - Backend: mock at the service boundary (axios, pg-promise, the MCP SDK) and
drive routes with supertest through a real
jwtMiddlewaretest stub reading anx-test-rolesheader. Path aliases (@utils/,@services/,@auth/,@middleware/,@routes/,@models/,@ronl/shared) are mapped inpackages/backend/jest.config.jsand work inside test files. - Frontend and public site: default to the
nodeVitest environment and opt intojsdomper file with// @vitest-environment jsdomonly when a component or DOM-touching hook needs it. Mock at the network boundary withmsw, not by stubbingaxiosmethods directly. Usevi.hoistedfor any mock referenced inside avi.mockfactory. - Shared mocks and fixtures used by more than one test file in a directory go
in a local
__helpers__subdirectory — do not create one until a second file actually needs to share something. - Scope large containers to critical interactions, not exhaustive branch coverage: mock every child component or context one level below what the container under test actually consumes, since those children have their own test files.
- Keep wall-clock assertions out of the main suite. A budget asserted against
performance.now()measures the machine as much as the code once 133 test files compete for cores. Name such a spec*.perf.test.ts: the default run excludes them andnpm run test:perfexecutes them without file parallelism, as its own CI step. - Update the counts on these pages when work lands, from a real run
(
--json --outputFile=…for Jest,--reporter=json --outputFile=…for Vitest) rather than an estimate or a grep forit(/test(— both miscount multi-line and parameterised cases.
Make every test file a module¶
A .ts file with no top-level import or export is a global script to
TypeScript, not a module. Every top-level declaration in it lands in the global
scope, where it collides with identically-named declarations in sibling files.
Nine backend test files were in exactly that state — using jest.mock() and
require() inside functions, with no top-level import — and ten names collided
across them: mockAxios in five files, Mod and freshModule in six each,
Handler / listTools / callTool / call / textOf / mockServer across
the three mcp-servers suites, and RSS in two.
The fix is one line per file:
This was invisible locally for weeks. ts-jest caches type diagnostics per file, so a warm cache skipped re-checking the collision and every local run was green. The first CI run on a cold runner failed immediately. Which files fail also varies, because it depends on the order the type program reaches them — CI reported three, a cold local run reported two, and all nine were latent regardless.
Run that before trusting a green backend suite to mean the code compiles.
A throttled run is not a failing run¶
pa-live-authoring failed roughly every other run. Two diagnoses were written
and both were wrong — a slow stack, then a post-login token race — and neither
had evidence behind it.
Instrumenting the failing run with a response listener showed six consecutive
429s. The backend allowed 100 requests per minute per IP, and one short
authoring journey measures 21 requests to /v1/pa/*, so two specs back to back
exhausted the budget. The UI renders a 429 as "Kon dossiers niet laden",
which is indistinguishable from a backend that is down.
e2e/helpers/rate-limit.ts now records the first 429 of a run and fails the
test with a message naming the throttle. It deliberately does not retry or
wait it out — a test that silently absorbs a throttle is a test that will
mislead the next person too. The shipped default was raised to 1000/min in
config.ts; the limiter keys on IP, so TRUST_PROXY decides whether that
budget is per user or per deployment.
The lesson generalises: when a test fails intermittently, instrument before theorising. Both wrong diagnoses were plausible, and one response listener settled it in a single run.
locator.count() and locator.isVisible() do not auto-wait¶
An afterEach cleanup guarded with if (await count()) read 0 before the
list had rendered, silently deleted nothing, and left ten test dossiers in the
dev database.
A cleanup that quietly does nothing is indistinguishable from one that worked.
Cleanup now waits with expect(...).toBeVisible() and warns rather than
swallowing.
Most Playwright assertions auto-wait; these two query methods do not. That asymmetry is easy to miss precisely because everything around them is forgiving.
A hand-written mock can pass vacuously¶
expectMockNamesRealExports was first written comparing import(path) against
the mock — but the path is mocked, so it compared the mock with itself and would
have passed no matter what.
It was caught only by deliberately injecting a bogus export name and noticing that the assertion failed to fail. That is the check worth internalising: a new assertion is not trustworthy until you have seen it go red for the right reason.
Both helpers — packages/frontend/src/test/mockModule.ts and
packages/backend/src/test-utils/mockModule.ts — now require
vi.importActual / jest.requireActual, and say so in their own doc comments.
The companion pattern, spreading the real module before the overrides, is what
stops an export added later from silently becoming undefined. That is exactly
how EU_DOCUMENT_TYPES disappeared from a mock and made GET /v1/pa/types
answer 500 while every test passed.
A related guard, packages/frontend/src/test/paData.stub.ts and its parity
test, renders the real PaDataProvider and asserts key equality in both
directions against the canonical stub. Adding a member to the context without
adding it to the stub now fails once, loudly, instead of surfacing as an
unhandled rejection inside whichever component happens to call it first — which
is how a CI run failed in this window with 1070 tests passing.