Testing
Use tests as design feedback and regression protection. Prefer tests that prove
observable behaviour through public APIs over tests that mirror implementation
structure.
Decision Tree
What is the task?
|-- New behaviour or bug fix
| |-- Can the behaviour be observed through a public API? -> write the test there
| `-- Is the seam missing or awkward? -> simplify the design before adding doubles
|-- Refactor existing code
| |-- Behaviour already covered? -> refactor behind the tests
| `-- Behaviour not covered? -> characterise it first, then refactor
|-- Invariant or illegal state to protect
| |-- Can a type make it unrepresentable? -> change the type, skip the test
| `-- Large input space? -> property-based tests alongside named examples
|-- Flaky or brittle tests
| `-- remove time/order/network coupling and implementation-detail assertions
`-- Coverage report, thresholds, or CI/hook enforcement
`-- Use the test-coverage skill
Core Rules
- Prefer TDD for behavioural changes: see the failure, make it pass, then
refactor.
- Test observable behaviour through public APIs, not implementation details.
- Keep tests deterministic and order-independent.
- Make each test's why clear from its name, setup, and assertions.
- A failing test should point at the cause quickly; vague failures are test
design problems.
- Prefer real values and simple pure tests before introducing doubles.
Types Before Tests
The cheapest test is one you never write because the bug cannot compile. Before
testing an invariant, ask whether a type can make the bad state unrepresentable -
a constrained type with a smart constructor, a union per lifecycle state,
NonEmptyList for "at least one". If it can, change the type instead: that is a
compile-time check that deletes a whole class of guard tests.
- Don't test what the type forbids. Feeding illegal values to an internal
function that cannot receive them tests the language, not your code. Validate
once at a boundary parser / smart constructor, and trust the type inward - an
always-valid model needs no defensive re-tests at every layer.
- Relocate, don't delete. Making a value a type moves the tests to the
constructor - but test the rules you authored, not the engine. A hand-rolled
smart constructor is logic you wrote: cover valid-accepted, invalid-rejected,
and edges. A schema-library parser (zod, valibot, pydantic) is declarative
config over a pre-tested engine: test only your custom refinements, transforms,
and cross-field constraints - re-testing the library's primitives is testing
the framework. Downstream code then needs no tests for inputs it can't hold.
- The guarantee is only as strong as the boundary. Where types are erased at
runtime (TypeScript), "trust the type inward" holds only if the boundary did a
real runtime parse - a schema library, not a cast (
as) or any. A type is a
compile-time claim the parser makes true at runtime. What no library covers:
whether your schema matches what the producer actually sends - guard that drift
with a contract test or a captured sample (see Contract Testing).
- Define errors out of existence, too. Redefining an operation so the edge
case is normal - a total function over a tested special case - deletes its
edge-case test with it. Design mechanic in
architecture → Error Handling.
Choosing the Layer
Choose the narrowest layer that proves the behaviour.
| Layer |
Use for |
Shape |
| Pure core |
Business rules, parsing, validation, calculations |
Unit tests with real values |
| Application/use case |
Decisions across owned ports |
Public API tests with fakes for owned ports |
| Adapter |
Database, queue, filesystem, third-party integration code |
Contract/integration tests against real infrastructure where practical |
| Scenario |
Critical vertical journeys across routing, auth, UI/API, and owned state |
Real app composition, real owned infrastructure where cheap, fake only externals |
| Composition |
Wiring, CLI, HTTP handlers, UI journeys |
A few integration/e2e checks for critical paths |
Do not use e2e tests to compensate for untested domain logic. Do not use unit
tests to assert wiring that only fails when components are composed.
Test altitude: aim at the stable contract
The "unit" worth testing is a behaviour through a stable public contract, not a
layer or a class (Ian Cooper, "TDD, Where Did It All Go Wrong"). So don't pick a
layer - aim each test at the deepest stable interface that keeps setup cheap and
failures localised; the level you land on is a consequence, not a goal.
- Depth, not height, buys refactor-resilience. A deep module - a narrow
interface over a rich, churning implementation - changes its interface rarely
while its body churns, so a test pinned to that interface survives the refactor
and still exercises everything behind it (
architecture → Module Depth). A
shallow interface is never a good target: make the module deep first, or test
the pure core beneath it. Tests bound to internal structure (class shape,
private methods) are glue that pins the design - the cost this rule avoids.
- Push combinatorial coverage down to the pure core. A pure function's
signature is already a stable contract, so cover branchy logic (pricing, rules
matrices, parsers) directly with table or property-based tests - don't enumerate
its cases through a deep facade, which adds setup, blurs shrinking, and buries
the failure.
- Keep a low-gear inner loop while a contract is molten. Testing close to the
code is where you feel the friction that drives a better design. That coupling
is temporary and the point; once the design settles, lift the bulk to the stable
interface and delete the scaffolding a higher test now subsumes.
"Stable" is an end-state: while the contract churns, low gear is correct, not a
fallback. Exercise the stable interface heavily for representative behaviours and
integration - but it is only genuinely cheap once builders/fakes make the arrange
step cheap.
Shell, zsh and POSIX sh testing
Choose shell test tooling by the language boundary being tested:
- Bash-heavy CLI/code:
bats-core is a good default.
- Cross-shell shell functions/libraries: prefer
ShellSpec.
- POSIX sh portability: run the same behaviour tests under multiple real shells
(
dash, busybox sh, bash --posix, etc.).
- zsh-native functions/autoloads/completions: test inside
zsh with isolated
shell state; Bats can black-box execute zsh commands but is not a zsh-native
test language.
Prefer black-box CLI tests for scripts: arguments, exit status, stdout, stderr,
and filesystem effects. For shell functions, isolate PATH, HOME/ZDOTDIR,
temp dirs, fixtures, and shell options.
See references/shell-testing.md for tool comparison, zsh isolation patterns,
POSIX multi-shell loops, example harnesses, driving a tty (and answering a
prompt, which script(1) cannot do), and keeping suites fast.
Scenario (integration) tests
A useful named layer sits between application and composition: run the real
application end to end and fake only the externals you don't own
(third-party HTTP, email delivery, payment providers), switching the faked
external state per test.
- Cover named user/business journeys, not matrices: lifecycle transitions,
permissions, locale, persistence, resumability, and other places where several
boundaries must agree.
- Use app-owned setup and cleanup surfaces when they exist - admin APIs, public
APIs, CLI commands - so the test validates the same contracts operators and
users rely on. Direct storage setup is a fallback when the public setup seam is
absent or prohibitively slow.
- Fake at the network boundary (e.g. MSW server-side), not by stubbing your own
modules - routing, parsing, middleware, and wiring all run for real.
- Define named backend states ("payment succeeds", "auth times out") and select
one per test instead of restarting the app or re-mocking by hand.
- Isolate both browser state and backend state: create unique records, clean them
up in fixture teardown, restore any global flags, and tag parallel tests with
an id (e.g. an injected header) so concurrent tests don't share mocked state.
- Prefer user-facing selectors and web-first assertions. Add stable accessible
scopes to repeated UI regions (e.g. a named
role="group") instead of
reaching for DOM structure.
- Keep slow scenario suites on an explicit command unless they are tiny and
mission-critical for every commit. Run serially only for unavoidable global
state, and say why.
Reach for it to prove a vertical slice works without standing up real
third-party services. It complements, and does not replace, a few true e2e
checks and exhaustive domain tests. See
references/scenario-testing.md when designing a scenario suite, choosing
setup/cleanup seams, or debugging scenario flakiness.
Testing at multiple boundaries
"Narrowest layer" is the default, not an absolute. Deliberately re-test the same
business rule at more than one boundary (e.g. the domain function and the
HTTP API) when defense in depth earns the duplication:
- Duplicate business rules, not plumbing. Re-prove a rule at each public
entry point; test plumbing (status codes, parsing, DOM details) only at the
layer it lives in.
- Why pay for it: which layer's test fails tells you which boundary broke;
two layers re-implementing a rule surface drift the moment one changes; the
rule survives as suites erode.
- The cost is real: every rule change touches every layer that asserts it.
Stop duplicating and sample at the outer layer instead when: the inner layer
becomes a thin wrapper (ceremony outweighs the rule), the outer surface explodes
to many endpoints, suite runtime crosses a pain threshold (keep the domain
exhaustive, sample the API), or there is only ever one consumer behind a trivial
forwarder.
Test Doubles
Avoid mocks by default; they tend to couple tests to call order and internal
collaboration.
- Pure core should not need doubles.
- Use fakes for ports you own when real infrastructure would make tests slow or
nondeterministic.
- Test adapters with real infrastructure where feasible, or with contract tests
that prove the adapter fulfils the port.
- For expensive or hostile external systems, fake at an application-owned port
and keep at least one smoke/integration check where practical.
- If a test needs many mocks, reconsider the boundary rather than adding more
mocking.
The mirror-test trap: a test that mocks the very collaborator whose
behaviour it claims to verify proves wiring, not behaviour - and stays green
when the real behaviour breaks. A handler test that stubs validateBooking to
return a rejection and then asserts the handler returns 400 never exercises
the real rule: delete the rule and the test still passes, because the test
supplied the rejection itself. Such tests also survive mutation of the mocked
unit. Assert against the real collaborator, or prove the rule at its own layer.
Property-Based Tests
Use property-based tests when examples under-sample the behaviour:
- parsers and serialisers
- normalisation and canonicalisation
- permissions matrices
- state machines
- ordering, sorting, deduplication
- arithmetic, date/time, ranges
- round trips and invariants
Write properties as invariants over generated inputs, not randomised examples.
Keep generators valid by construction where possible. When an invariant is
structural (non-empty, bounded, exactly-one), prefer encoding it as a type
(e.g. NonEmptyList) over a "never empty" property - a compile-time guarantee
beats a sampled one and needs no generator. Keep named example tests
for edge cases and regression stories; use property tests to explore the input
space around them.
Failures shrink automatically to a minimal counterexample - persist that case
as a regression example so the specific failure is checked deterministically
forever. For stateful systems, generate a sequence of operations and check them
against a simple in-memory model (model-based testing).
See property-based-testing.md for
per-ecosystem frameworks (fast-check, Hypothesis, proptest, rapid/gopter),
shrinking, stateful/model-based testing, CI integration, and pitfalls.
Differential & Metamorphic Testing
Use these when there is no reliable oracle - you cannot state the correct
output, only relationships between outputs. They are the backbone of compiler,
parser, database, numeric, and ML testing.
- Differential: run the same input through two independent implementations
(or old vs new version) and assert they agree. Cheap and powerful for safe
refactors and for parsers/compilers - keep the reference implementation as the
oracle.
- Metamorphic: assert a relation between related inputs when no single
output is checkable -
sin(x) == sin(pi - x); permuting training data should
not change a model's accuracy; add-then-remove restores state. Usually
expressed as a property (see above), so reach for your PBT framework.
Snapshot & Approval Tests
Snapshot tools (Jest/Vitest snapshots, insta for Rust, syrupy for Python,
ApprovalTests) record output and diff future runs against it. Useful for large,
semantically meaningful serialised output - but they fail open and degrade:
- Snapshot rot / rubber-stamping: when a snapshot breaks, the path of least
resistance is update-and-merge, so the snapshot ends up asserting "what the
code currently does", not what it should.
- Over-broad snapshots bury the one meaningful line among hundreds of
irrelevant ones; every change churns the snapshot and nobody reads the diff.
- Regenerate from a fresh build, and gate the guard. When a golden is
produced by a generator that reads build output (a script over
dist/),
regenerate only after a fresh build - a stale build bakes stale goldens that
pass review while being wrong. And an upstream change to a derived input
(adopting a formatter, bumping a codegen tool) rots every golden with no test
edit; if the golden guard is not in the commit gate, the drift lands silently.
Keep the regen guard in CI/pre-commit, or regenerate deliberately in the same
change and review the diff.
Use them well: keep snapshots small and targeted (snapshot the one derived
value, not the whole DOM/object), review every update as real code, and prefer
explicit assertions whenever you can name the expectation. Treat a snapshot-only
test as roughly assertion-free for quality purposes. Avoid snapshots for
incidental structure.
Assertion Quality
A test with no assertion only proves "it did not throw". Make each test's
assertions name the behaviour they protect. As a cheap guard, flag
assertion-free tests in lint/CI (e.g. ESLint jest/expect-expect, or AST/grep
checks for test functions lacking assert/expect/require). Assertion
count is a weak, gameable proxy - the rigorous measure of "do my assertions
actually catch bugs" is mutation testing, owned by the test-coverage skill.
Contract Testing
Two senses, both about proving a boundary without a full end-to-end stack:
- Adapter/port contract (within one codebase): one test suite run against
both the real adapter and any fake proves they satisfy the same port. Prefer
this over mocks for owned ports (see Test Doubles).
- Consumer-driven contract (across independently deployed services, e.g.
Pact): the consumer publishes the requests/responses it relies on; the
provider verifies it still satisfies them. Sits between integration and e2e,
catching cross-service breaks cheaply. Pitfalls: broker/tooling overhead and
false confidence if contracts drift from real usage. For HTTP, schema/OpenAPI
contract testing is lighter when one side owns the spec.
Refactoring Existing Code
Before refactoring, characterise current behaviour through public APIs. Commit
those tests separately while the old implementation still exists. Then refactor
behind the tests.
If behaviour is unclear, preserve it first and ask before changing it. For
characterisation tests on large output, prefer golden/approval over snapshots
(see Snapshot & Approval Tests).
Fixing Bugs
Prove the bug is detectable before fixing it. Add or adjust a failing test,
enable the strict check, or reproduce the failing command. The red step does not
need a commit, but it should be real enough to prove the fix.
After the fix, run the narrowest relevant check first, then the broader checks
needed for confidence.
Flaky Tests
A flaky test (passes and fails on the same code) erodes trust in the whole
suite. Retry-to-green is an anti-pattern - auto-rerunning until a pass hides
a real defect (usually a race, shared state, or order-dependency) and lets it
ship.
- Detect: re-run suspected tests to surface flakiness, not mask it
(
pytest-rerunfailures, go test -count=N, seed/order shuffling to expose
order-dependence). CI test-analytics (Datadog, Buildkite, GitHub) track
per-test pass/fail history over time.
- Quarantine, then fix: move a confirmed-flaky test out of the blocking gate
into a tracked quarantine with an owner and a deadline - do not
skip and
forget, and do not leave it blocking the build. Root-cause it: timing, shared
state, network, or nondeterministic ordering.
- Bounded polling is not retry-to-green. Polling a genuinely asynchronous
result until it appears, with a timeout, is correct; re-running a whole test
until it passes masks a race. The first waits for a known-pending outcome; the
second hides nondeterminism.
Prevention is design: keep tests deterministic and order-independent, and remove
time/network coupling (see Core Rules).
References
- property-based-testing.md -
Per-ecosystem PBT frameworks, shrinking, stateful/model-based testing, CI
integration, and pitfalls.
- scenario-testing.md - Critical-journey
selection, setup/cleanup through public surfaces, external fakes, accessible
selectors, runtime policy, and anti-patterns for scenario/e2e suites.
- shell-testing.md - Bats/ShellSpec/shUnit2/cram
trade-offs, zsh isolation, POSIX multi-shell testing, shell fakes, driving a
tty and answering a prompt on a pty, and keeping suites fast (parallelism,
fixture amortisation, removing time-coupling).
- For coverage reports, thresholds, exclusions, mutation testing, fuzzing,
and CI/hook enforcement of test quality, use the test-coverage skill.
1---2name: testing3description: Design and write effective tests for behavioural changes, bug fixes, and refactors. Use when choosing a test layer, practising TDD, picking doubles/fakes, designing scenario or e2e suites for critical journeys, reducing brittle or flaky tests, designing the characterisation tests that make refactoring safe, or applying property-based, snapshot/approval, differential/metamorphic, or contract testing. For coverage, thresholds, mutation testing, fuzzing, and CI/hook enforcement, use the test-coverage skill; for legacy migration and refactoring strategy, use the refactoring skill.4---5
6# Testing
7
8Use tests as design feedback and regression protection. Prefer tests that prove
9observable behaviour through public APIs over tests that mirror implementation
10structure.
11
12## Decision Tree
13
14```text
15What is the task?
16|-- New behaviour or bug fix
17| |-- Can the behaviour be observed through a public API? -> write the test there
18| `-- Is the seam missing or awkward? -> simplify the design before adding doubles
19|-- Refactor existing code
20| |-- Behaviour already covered? -> refactor behind the tests
21| `-- Behaviour not covered? -> characterise it first, then refactor
22|-- Invariant or illegal state to protect
23| |-- Can a type make it unrepresentable? -> change the type, skip the test
24| `-- Large input space? -> property-based tests alongside named examples
25|-- Flaky or brittle tests
26| `-- remove time/order/network coupling and implementation-detail assertions
27`-- Coverage report, thresholds, or CI/hook enforcement
28 `-- Use the test-coverage skill
29```
30
31## Core Rules
32
33- Prefer TDD for behavioural changes: see the failure, make it pass, then
34 refactor.
35- Test observable behaviour through public APIs, not implementation details.
36- Keep tests deterministic and order-independent.
37- Make each test's why clear from its name, setup, and assertions.
38- A failing test should point at the cause quickly; vague failures are test
39 design problems.
40- Prefer real values and simple pure tests before introducing doubles.
41
42## Types Before Tests
43
44The cheapest test is one you never write because the bug cannot compile. Before
45testing an invariant, ask whether a type can make the bad state unrepresentable -
46a constrained type with a smart constructor, a union per lifecycle state,
47`NonEmptyList` for "at least one". If it can, change the type instead: that is a
48compile-time check that deletes a whole class of guard tests.
49
50- **Don't test what the type forbids.** Feeding illegal values to an internal
51 function that cannot receive them tests the language, not your code. Validate
52 once at a boundary parser / smart constructor, and trust the type inward - an
53 always-valid model needs no defensive re-tests at every layer.
54- **Relocate, don't delete.** Making a value a type moves the tests to the
55 constructor - but test the rules *you* authored, not the engine. A hand-rolled
56 smart constructor is logic you wrote: cover valid-accepted, invalid-rejected,
57 and edges. A schema-library parser (zod, valibot, pydantic) is declarative
58 config over a pre-tested engine: test only your custom refinements, transforms,
59 and cross-field constraints - re-testing the library's primitives is testing
60 the framework. Downstream code then needs no tests for inputs it can't hold.
61- **The guarantee is only as strong as the boundary.** Where types are erased at
62 runtime (TypeScript), "trust the type inward" holds only if the boundary did a
63 real runtime parse - a schema library, not a cast (`as`) or `any`. A type is a
64 compile-time claim the parser makes true at runtime. What no library covers:
65 whether your schema matches what the producer actually sends - guard that drift
66 with a contract test or a captured sample (see Contract Testing).
67- **Define errors out of existence, too.** Redefining an operation so the edge
68 case is normal - a total function over a tested special case - deletes its
69 edge-case test with it. Design mechanic in `architecture` → Error Handling.
70
71## Choosing the Layer
72
73Choose the narrowest layer that proves the behaviour.
74
75| Layer | Use for | Shape |
76|---|---|---|
77| Pure core | Business rules, parsing, validation, calculations | Unit tests with real values |
78| Application/use case | Decisions across owned ports | Public API tests with fakes for owned ports |
79| Adapter | Database, queue, filesystem, third-party integration code | Contract/integration tests against real infrastructure where practical |
80| Scenario | Critical vertical journeys across routing, auth, UI/API, and owned state | Real app composition, real owned infrastructure where cheap, fake only externals |
81| Composition | Wiring, CLI, HTTP handlers, UI journeys | A few integration/e2e checks for critical paths |
82
83Do not use e2e tests to compensate for untested domain logic. Do not use unit
84tests to assert wiring that only fails when components are composed.
85
86### Test altitude: aim at the stable contract
87
88The "unit" worth testing is a *behaviour through a stable public contract*, not a
89layer or a class (Ian Cooper, "TDD, Where Did It All Go Wrong"). So don't pick a
90layer - **aim each test at the deepest stable interface that keeps setup cheap and
91failures localised**; the level you land on is a consequence, not a goal.
92
93- **Depth, not height, buys refactor-resilience.** A deep module - a narrow
94 interface over a rich, churning implementation - changes its interface rarely
95 while its body churns, so a test pinned to that interface survives the refactor
96 and still exercises everything behind it (`architecture` → Module Depth). A
97 *shallow* interface is never a good target: make the module deep first, or test
98 the pure core beneath it. Tests bound to internal structure (class shape,
99 private methods) are glue that pins the design - the cost this rule avoids.
100- **Push combinatorial coverage down to the pure core.** A pure function's
101 signature is already a stable contract, so cover branchy logic (pricing, rules
102 matrices, parsers) directly with table or property-based tests - don't enumerate
103 its cases through a deep facade, which adds setup, blurs shrinking, and buries
104 the failure.
105- **Keep a low-gear inner loop while a contract is molten.** Testing close to the
106 code is where you feel the friction that drives a better design. That coupling
107 is temporary and the point; once the design settles, lift the bulk to the stable
108 interface and delete the scaffolding a higher test now subsumes.
109
110"Stable" is an end-state: while the contract churns, low gear is correct, not a
111fallback. Exercise the stable interface heavily for representative behaviours and
112integration - but it is only genuinely cheap once builders/fakes make the arrange
113step cheap.
114
115### Shell, zsh and POSIX sh testing
116
117Choose shell test tooling by the language boundary being tested:
118
119- **Bash-heavy CLI/code:** `bats-core` is a good default.
120- **Cross-shell shell functions/libraries:** prefer `ShellSpec`.
121- **POSIX sh portability:** run the same behaviour tests under multiple real shells
122 (`dash`, `busybox sh`, `bash --posix`, etc.).
123- **zsh-native functions/autoloads/completions:** test inside `zsh` with isolated
124 shell state; Bats can black-box execute zsh commands but is not a zsh-native
125 test language.
126
127Prefer black-box CLI tests for scripts: arguments, exit status, stdout, stderr,
128and filesystem effects. For shell functions, isolate `PATH`, `HOME`/`ZDOTDIR`,
129temp dirs, fixtures, and shell options.
130
131See `references/shell-testing.md` for tool comparison, zsh isolation patterns,
132POSIX multi-shell loops, example harnesses, driving a tty (and answering a
133prompt, which `script(1)` cannot do), and keeping suites fast.
134
135### Scenario (integration) tests
136
137A useful named layer sits between application and composition: run the **real
138application** end to end and fake **only the externals you don't own**
139(third-party HTTP, email delivery, payment providers), switching the faked
140external state per test.
141
142- Cover named user/business journeys, not matrices: lifecycle transitions,
143 permissions, locale, persistence, resumability, and other places where several
144 boundaries must agree.
145- Use app-owned setup and cleanup surfaces when they exist - admin APIs, public
146 APIs, CLI commands - so the test validates the same contracts operators and
147 users rely on. Direct storage setup is a fallback when the public setup seam is
148 absent or prohibitively slow.
149- Fake at the network boundary (e.g. MSW server-side), not by stubbing your own
150 modules - routing, parsing, middleware, and wiring all run for real.
151- Define named backend states ("payment succeeds", "auth times out") and select
152 one per test instead of restarting the app or re-mocking by hand.
153- Isolate both browser state and backend state: create unique records, clean them
154 up in fixture teardown, restore any global flags, and tag parallel tests with
155 an id (e.g. an injected header) so concurrent tests don't share mocked state.
156- Prefer user-facing selectors and web-first assertions. Add stable accessible
157 scopes to repeated UI regions (e.g. a named `role="group"`) instead of
158 reaching for DOM structure.
159- Keep slow scenario suites on an explicit command unless they are tiny and
160 mission-critical for every commit. Run serially only for unavoidable global
161 state, and say why.
162
163Reach for it to prove a vertical slice works without standing up real
164third-party services. It complements, and does not replace, a few true e2e
165checks and exhaustive domain tests. See
166`references/scenario-testing.md` when designing a scenario suite, choosing
167setup/cleanup seams, or debugging scenario flakiness.
168
169### Testing at multiple boundaries
170
171"Narrowest layer" is the default, not an absolute. Deliberately re-test the same
172**business rule** at more than one boundary (e.g. the domain function *and* the
173HTTP API) when defense in depth earns the duplication:
174
175- **Duplicate business rules, not plumbing.** Re-prove a rule at each public
176 entry point; test plumbing (status codes, parsing, DOM details) only at the
177 layer it lives in.
178- **Why pay for it:** which layer's test fails tells you which boundary broke;
179 two layers re-implementing a rule surface drift the moment one changes; the
180 rule survives as suites erode.
181- **The cost is real:** every rule change touches every layer that asserts it.
182
183Stop duplicating and sample at the outer layer instead when: the inner layer
184becomes a thin wrapper (ceremony outweighs the rule), the outer surface explodes
185to many endpoints, suite runtime crosses a pain threshold (keep the domain
186exhaustive, sample the API), or there is only ever one consumer behind a trivial
187forwarder.
188
189## Test Doubles
190
191Avoid mocks by default; they tend to couple tests to call order and internal
192collaboration.
193
194- Pure core should not need doubles.
195- Use fakes for ports you own when real infrastructure would make tests slow or
196 nondeterministic.
197- Test adapters with real infrastructure where feasible, or with contract tests
198 that prove the adapter fulfils the port.
199- For expensive or hostile external systems, fake at an application-owned port
200 and keep at least one smoke/integration check where practical.
201- If a test needs many mocks, reconsider the boundary rather than adding more
202 mocking.
203
204**The mirror-test trap:** a test that mocks the very collaborator whose
205behaviour it claims to verify proves *wiring*, not behaviour - and stays green
206when the real behaviour breaks. A handler test that stubs `validateBooking` to
207return a rejection and then asserts the handler returns `400` never exercises
208the real rule: delete the rule and the test still passes, because the test
209supplied the rejection itself. Such tests also survive mutation of the mocked
210unit. Assert against the real collaborator, or prove the rule at its own layer.
211
212## Property-Based Tests
213
214Use property-based tests when examples under-sample the behaviour:
215
216- parsers and serialisers
217- normalisation and canonicalisation
218- permissions matrices
219- state machines
220- ordering, sorting, deduplication
221- arithmetic, date/time, ranges
222- round trips and invariants
223
224Write properties as invariants over generated inputs, not randomised examples.
225Keep generators valid by construction where possible. When an invariant is
226*structural* (non-empty, bounded, exactly-one), prefer encoding it as a type
227(e.g. `NonEmptyList`) over a "never empty" property - a compile-time guarantee
228beats a sampled one and needs no generator. Keep named example tests
229for edge cases and regression stories; use property tests to explore the input
230space around them.
231
232Failures shrink automatically to a minimal counterexample - persist that case
233as a regression example so the specific failure is checked deterministically
234forever. For stateful systems, generate a sequence of operations and check them
235against a simple in-memory model (model-based testing).
236
237See [property-based-testing.md](references/property-based-testing.md) for
238per-ecosystem frameworks (fast-check, Hypothesis, proptest, rapid/gopter),
239shrinking, stateful/model-based testing, CI integration, and pitfalls.
240
241## Differential & Metamorphic Testing
242
243Use these when there is **no reliable oracle** - you cannot state the correct
244output, only relationships between outputs. They are the backbone of compiler,
245parser, database, numeric, and ML testing.
246
247- **Differential:** run the same input through two independent implementations
248 (or old vs new version) and assert they agree. Cheap and powerful for safe
249 refactors and for parsers/compilers - keep the reference implementation as the
250 oracle.
251- **Metamorphic:** assert a *relation* between related inputs when no single
252 output is checkable - `sin(x) == sin(pi - x)`; permuting training data should
253 not change a model's accuracy; add-then-remove restores state. Usually
254 expressed as a property (see above), so reach for your PBT framework.
255
256## Snapshot & Approval Tests
257
258Snapshot tools (Jest/Vitest snapshots, insta for Rust, syrupy for Python,
259ApprovalTests) record output and diff future runs against it. Useful for large,
260semantically meaningful serialised output - but they fail *open* and degrade:
261
262- **Snapshot rot / rubber-stamping:** when a snapshot breaks, the path of least
263 resistance is update-and-merge, so the snapshot ends up asserting "what the
264 code currently does", not what it *should*.
265- **Over-broad snapshots** bury the one meaningful line among hundreds of
266 irrelevant ones; every change churns the snapshot and nobody reads the diff.
267- **Regenerate from a fresh build, and gate the guard.** When a golden is
268 produced by a generator that reads build output (a script over `dist/`),
269 regenerate only after a fresh build - a stale build bakes stale goldens that
270 pass review while being wrong. And an upstream change to a *derived input*
271 (adopting a formatter, bumping a codegen tool) rots every golden with no test
272 edit; if the golden guard is not in the commit gate, the drift lands silently.
273 Keep the regen guard in CI/pre-commit, or regenerate deliberately in the same
274 change and review the diff.
275
276Use them well: keep snapshots **small and targeted** (snapshot the one derived
277value, not the whole DOM/object), review every update as real code, and prefer
278explicit assertions whenever you can name the expectation. Treat a snapshot-only
279test as roughly assertion-free for quality purposes. Avoid snapshots for
280incidental structure.
281
282## Assertion Quality
283
284A test with no assertion only proves "it did not throw". Make each test's
285assertions name the behaviour they protect. As a cheap guard, flag
286assertion-free tests in lint/CI (e.g. ESLint `jest/expect-expect`, or AST/grep
287checks for test functions lacking `assert`/`expect`/`require`). Assertion
288*count* is a weak, gameable proxy - the rigorous measure of "do my assertions
289actually catch bugs" is **mutation testing**, owned by the test-coverage skill.
290
291## Contract Testing
292
293Two senses, both about proving a boundary without a full end-to-end stack:
294
295- **Adapter/port contract** (within one codebase): one test suite run against
296 both the real adapter and any fake proves they satisfy the same port. Prefer
297 this over mocks for owned ports (see Test Doubles).
298- **Consumer-driven contract** (across independently deployed services, e.g.
299 Pact): the consumer publishes the requests/responses it relies on; the
300 provider verifies it still satisfies them. Sits *between* integration and e2e,
301 catching cross-service breaks cheaply. Pitfalls: broker/tooling overhead and
302 false confidence if contracts drift from real usage. For HTTP, schema/OpenAPI
303 contract testing is lighter when one side owns the spec.
304
305## Refactoring Existing Code
306
307Before refactoring, characterise current behaviour through public APIs. Commit
308those tests separately while the old implementation still exists. Then refactor
309behind the tests.
310
311If behaviour is unclear, preserve it first and ask before changing it. For
312characterisation tests on large output, prefer golden/approval over snapshots
313(see Snapshot & Approval Tests).
314
315## Fixing Bugs
316
317Prove the bug is detectable before fixing it. Add or adjust a failing test,
318enable the strict check, or reproduce the failing command. The red step does not
319need a commit, but it should be real enough to prove the fix.
320
321After the fix, run the narrowest relevant check first, then the broader checks
322needed for confidence.
323
324## Flaky Tests
325
326A flaky test (passes and fails on the same code) erodes trust in the whole
327suite. **Retry-to-green is an anti-pattern** - auto-rerunning until a pass hides
328a real defect (usually a race, shared state, or order-dependency) and lets it
329ship.
330
331- **Detect:** re-run suspected tests to *surface* flakiness, not mask it
332 (`pytest-rerunfailures`, `go test -count=N`, seed/order shuffling to expose
333 order-dependence). CI test-analytics (Datadog, Buildkite, GitHub) track
334 per-test pass/fail history over time.
335- **Quarantine, then fix:** move a confirmed-flaky test out of the blocking gate
336 into a tracked quarantine with an owner and a deadline - do not `skip` and
337 forget, and do not leave it blocking the build. Root-cause it: timing, shared
338 state, network, or nondeterministic ordering.
339- **Bounded polling is not retry-to-green.** Polling a genuinely asynchronous
340 result until it appears, with a timeout, is correct; re-running a whole test
341 until it passes masks a race. The first waits for a known-pending outcome; the
342 second hides nondeterminism.
343
344Prevention is design: keep tests deterministic and order-independent, and remove
345time/network coupling (see Core Rules).
346
347## References
348
349- [property-based-testing.md](references/property-based-testing.md) -
350 Per-ecosystem PBT frameworks, shrinking, stateful/model-based testing, CI
351 integration, and pitfalls.
352- [scenario-testing.md](references/scenario-testing.md) - Critical-journey
353 selection, setup/cleanup through public surfaces, external fakes, accessible
354 selectors, runtime policy, and anti-patterns for scenario/e2e suites.
355- [shell-testing.md](references/shell-testing.md) - Bats/ShellSpec/shUnit2/cram
356 trade-offs, zsh isolation, POSIX multi-shell testing, shell fakes, driving a
357 tty and answering a prompt on a pty, and keeping suites fast (parallelism,
358 fixture amortisation, removing time-coupling).
359- For coverage reports, thresholds, exclusions, **mutation testing**, fuzzing,
360 and CI/hook enforcement of test quality, use the **test-coverage** skill.