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 five tested 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. -
A new backend route needs a registry entry, an OpenAPI description, and a conformance assertion in its route test. Since v2026.09.12
src/routes/registry.tsis the one list of/v1mounts:index.tsmounts from it, the root banner advertises from it, andsrc/openapi/coverage.test.tscompares it againstopenapi/openapi.yaml. Since v2026.09.14 there are two rules, and both are enforced by the ordinarynpm test:- Describe it in
openapi.yaml. An operation that is served but not documented โ or documented but not served โ failscoverage.test.ts. There is no pending list to park it on any more: #214 described the last undocumented operations andopenapi/pending.jsonwas deleted with its ceiling. - Assert that a real response matches the description. Call
expectToMatchOperation(res, '<method>', '<path>')fromsrc/openapi/testing/conformance.tsin the route test that exercises the operation. It checks the status, theAPI-Versionheader on a 2xx and the body against the documented schema, and it records the operation; after Jest,scripts/check-conformance-coverage.cjsfailsnpm testif any documented operation was never checked, naming each one.
A filtered Jest run skips that second check.
npx jest -t โฆ, a single file,test:contractandtest:openapi-coveragenever reach it, so a quick run can be green while an operation is still unchecked. Usenpm run test:contract --workspace=@ronl/backendfor a quick check,npm run lint:openapi --workspace=@ronl/backendfor the NL API Design Rules ruleset, which CI runs before the tests, and the fullnpm test --workspace=@ronl/backendbefore you push. - The four Vitest packages (frontend, pa-cockpit, pa-demo, public site): default to thenodeVitest 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 againstperformance.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. - Keep every file above 80% branches. Since v2026.09.6 that floor is configured per file in all five runner configs, so a thin new file does not merely look thin: it exitsnpm testnon-zero and names itself. It is a branch floor only โ the functions column carries no threshold, and adding one at 80 would fail 23 existing files as measured on 9 October 2026 (24 on 3 October, 23 on 30 September, and 26 on each of the four passes before that, which is the figure the configs' own comments now carry). - Describe it in
Keep a new file above 85 rather than aiming at 80. On 30 September every
file cleared 85%, five points above the floor: the three that sat at exactly
80.00% on 28 September went to 100% in 391b1a8, and 73a6764 lifted the 32
between 80 and 85. The margin is a practice, not a gate โ the configs still
say 80 โ and v2026.10.0 is the first release to land files below it:
frontend/src/components/CaseworkerDashboard/BesluitOverzichtSection.tsx at
80.43%, one uncovered branch from failing the gate, and
frontend/src/components/process/phaseSet.ts at 83.33%. Measured again on
9 October, those two are still the only files below 85 in the repository;
v2026.10.1 added none.
- Update the counts on these pages when work lands, from a real run
(--json --outputFile=โฆ for Jest, --reporter=json --outputFile.json=โฆ for
Vitest) rather than an estimate or a grep for it( / test( โ both miscount
multi-line and parameterised cases. Where a figure has to come from the
source instead, parse the test files and expand every .each table and
every loop that declares tests, resolve by hand any table that is not a
literal in the file โ a tenants.json read, a router's route layers โ and
publish it only if the per-file counts sum to the runner's own total. Give those output paths absolutely:
npm test --workspace=โฆ runs with the cwd set to the package, so a relative
one lands in packages/. Take the file count from testResults.length, not
from numTotalTestSuites, which counts describe blocks. For elapsed time quote Vitest's
Duration and Jest's Time:, never Vitest's aggregate tests โฆs line: it
sums per-worker time across parallel workers and reads several times longer
than anyone actually waited.
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.