Testing law
Testing law
Layer: is this change proven? The companion to CLEAN_CODE.md (is this line well
written?). clean-code-guard checks the code; test-guard checks the test. Entry point:
../CLAUDE.md.
The gate is simple: a change that can regress silently must ship a test that fails without it. The two incidents that cost us most — the CPU log-noise regression (R5) and the WhatsApp retry storm (R6) — both shipped green because nothing asserted the behavior they broke. This doc closes that.
Today: 490 unit/integration specs + 16 e2e, jest with ts-jest, *.spec.ts under src/, e2e in
test/. No coverage threshold is enforced yet (see §6).
1. What MUST have a test (the gate)
If your change is any of these, it is not done without a test:
| Change | Test that must exist |
|---|---|
| A bug fix | A test that fails on the old code and passes on the fix. Write it first, watch it fail. |
| A branch on money, access, or tenancy | The allow path and every deny path (D level, wrong workspace, expired trial). |
| A permission / RLS / isolation rule | Prove the negative: the other workspace / lower level sees zero rows, not an error. |
| A pure function with real logic (parser, formatter, resolver, fold) | Its boundary cases (empty / one / many / null / off-by-one). |
| A field-type validator, workflow trigger/action, or provider adapter | One test per registered variant. |
| A regression you were asked to prevent "from happening again" | A named regression test that encodes the exact failure. |
| An invariant from CODE_PRINCIPLES ("an option link survives a rename") | One test asserting the invariant directly. |
Does NOT need a test (NESTJS_BEST_PRACTICES §13): TypeORM's own methods, NestJS decorators, trivial getters, DTO shape with no logic, a thin controller that only delegates. Don't pad coverage with these.
2. The one rule that matters most
Write the test that fails without your change. A test that passes whether or not the code is correct is worse than no test — it's a green light wired to nothing. For a bug fix, the sequence is: reproduce as a failing test → fix → test goes green. If you can't make it fail first, you don't yet understand the bug.
3. Test layers — what to mock
Mirror of ARCHITECTURE.md §15:
| Layer | Type | Mock |
|---|---|---|
| Repository / Query | integration | real DB (test tenant / :5434) |
| Service | unit | mocked repository, mocked adapters |
| Proxy | unit | mocked service, mocked cache |
| Adapter | unit | mocked public service of the other module |
| Controller / Gateway | e2e | real Nest app + real Redis |
| Listener | unit | emit the event, assert the side effect |
Co-locate unit/integration specs in the module's __tests__/. E2E lives in test/. Integration and e2e
need DB + Redis → run them via Docker (docker compose exec app npm test), not host npm
(CLAUDE.md §5).
4. Anti-patterns — an instant fail in review (CLEAN_CODE R18)
- Asserting on canned data the test itself fed in.
expect(x).toBe('ok')where'ok'is a hardcoded return, not computed by the code under test. Proves nothing. - Weakening a test to make it pass — loosening an assertion, widening a matcher,
try/catcharound a failingexpect. If the test is wrong, fix the test's premise; if the code is wrong, fix the code. .skip/xit/xdescribeleft in. A skipped test is a lie in the coverage number. Delete it or fix it..only/fdescribe/fitcommitted. Silently disables the rest of the suite — CI goes green on a fraction of the tests. CI-enforced (HARD):review-grep.shfails the build on these.- One giant test asserting ten things. When it fails you can't tell which. One behavior per test; the name states the behavior.
- Testing the mock, not the code. If every dependency is mocked and the assertion only checks a mock was called, you tested your wiring, not your logic.
5. The guardrail-spec pattern (enforce a rule as a test)
When a rule is a static invariant over the whole codebase — not a runtime behavior — encode it as a
scanning spec, not a doc nobody re-reads. Precedent:
rls-pinning.guardrail.spec.ts greps the tree for
unpinned DB access and fails if any appears;
api-auth-skip-permissions.guardrail.spec.ts
does the same for @SkipPermissions misuse. Use this when "nobody should ever write X" needs teeth that
survive past review.
6. Coverage
Target (NESTJS_BEST_PRACTICES §13): 80% branches / 80% functions / 85% lines. Run
npm run test:cov. Not yet gated — no coverageThreshold in the jest config. Coverage is a floor to
ratchet toward on touched modules, never a goal to hit with getter tests. Raising the gate is a team
decision (like the any lint severity); don't flip it in a feature PR.
7. test-guard — the review companion
After writing or changing tests, run the test-guard skill on them, the same way clean-code-guard
runs on the code (CLAUDE.md §1). It catches the §4 anti-patterns — assertion-free tests,
fixtures masquerading as results, skipped tests, mock-only assertions — that a coding agent produces by
default. A test file is not done until it has passed test-guard.
8. Owed regression tests (debt register)
These incidents are documented in CLEAN_CODE.md §7 but have no regression test, which is exactly how they'd recur. Each is owed a named test. Not written yet because the code under test is either uncommitted or scattered — writing tests against an assumed API would itself be the R18 antipattern.
| Rule | Incident | Test owed | Blocker |
|---|---|---|---|
| R5 | Log-string built on a hot path pinned prod CPU | Assert the lazy debugLog helper does not invoke its message builder when the level is off | Helper not on this branch (uncommitted per the CPU-campaign work) |
| R6 | A WAHA "revoked" response drove an unbounded retry storm | Assert a permanently-unrecoverable provider error is not retried; a transient one is, capped | Retry classification is spread across messaging/ with no single unit — needs the owner's context |
When either lands on a committed, stable API, write the test in the same change and strike it from this table.