Corteksa

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:

ChangeTest that must exist
A bug fixA test that fails on the old code and passes on the fix. Write it first, watch it fail.
A branch on money, access, or tenancyThe allow path and every deny path (D level, wrong workspace, expired trial).
A permission / RLS / isolation ruleProve 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 adapterOne 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:

LayerTypeMock
Repository / Queryintegrationreal DB (test tenant / :5434)
Serviceunitmocked repository, mocked adapters
Proxyunitmocked service, mocked cache
Adapterunitmocked public service of the other module
Controller / Gatewaye2ereal Nest app + real Redis
Listenerunitemit 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/catch around a failing expect. If the test is wrong, fix the test's premise; if the code is wrong, fix the code.
  • .skip / xit / xdescribe left in. A skipped test is a lie in the coverage number. Delete it or fix it.
  • .only / fdescribe / fit committed. Silently disables the rest of the suite — CI goes green on a fraction of the tests. CI-enforced (HARD): review-grep.sh fails 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.

RuleIncidentTest owedBlocker
R5Log-string built on a hot path pinned prod CPUAssert the lazy debugLog helper does not invoke its message builder when the level is offHelper not on this branch (uncommitted per the CPU-campaign work)
R6A WAHA "revoked" response drove an unbounded retry stormAssert a permanently-unrecoverable provider error is not retried; a transient one is, cappedRetry 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.

On this page