What makes a test worth keeping — seams, assertions, mocking, and the anti-patterns that produce tests which pass forever and catch nothing. A reference consulted before and while writing a test, not a procedure. Use when writing or reviewing tests, choosing where to test, or judging whether a test earns its maintenance.
Installs into .claude/skills of the current project.
Are you the author of Tdd?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/savinofiore-tdd)
---
name: tdd
description: What makes a test worth keeping — seams, assertions, mocking, and the anti-patterns that produce tests which pass forever and catch nothing. A reference consulted before and while writing a test, not a procedure. Use when writing or reviewing tests, choosing where to test, or judging whether a test earns its maintenance.
---
# tdd — tests worth keeping
A test earns its place by two properties: it fails when behaviour breaks, and it
stays quiet when only structure changes. Everything below serves those two and
nothing else.
This is a reference, not a procedure — the loop, the gate and the stop
conditions live in the `lexi` skill. Consult this before writing a test and
while writing it, not in review afterwards.
## Seams: where a test attaches
A **seam** is a boundary where behaviour is observable from outside: a public
function, a module's exported API, a request handler, a repository method.
Tests attach at seams and never reach behind them.
Behind the seam is implementation — private helpers, intermediate state, the
order of internal calls. A test that reaches there breaks on a rename and
survives a real regression, which is the exact inverse of what you want.
Choosing seams is a decision, not a discovery. Too shallow, every private
function pinned, and the suite ossifies: no refactor is possible without a test
rewrite. Too deep, only the top-level entry point, and a failure tells you the
system is broken but not where. Pick the boundary where the behaviour has a
name, agree it, and put the test there.
## Naming: the test states what the system does
A test name is a sentence about capability: *parses an ISO date without a
timezone as UTC*, *rejects a coupon after its expiry*. Read top to bottom, the
suite should list what the unit can do.
`works`, `test2`, `handles edge case` describe nothing. When a name is hard to
write, the test is usually covering more than one behaviour — split it.
## Assertions come from outside the code
An expected value must have an independent source: a literal worked out by
hand, an example from the spec, a fixture captured from the real system.
Never recompute the expectation the way the implementation computes it:
```
expect(total(items)).toBe(items.reduce((a, i) => a + i.price, 0)) // asserts nothing
expect(total(items)).toBe(47.50) // asserts something
```
The first agrees with the code by construction and can never disagree with it —
it is a copy of the implementation wearing a test's clothes. The same applies to
a snapshot generated by the code under test and approved without being read.
## Mocking
Replace what you don't own; use the real thing for everything else.
Network, clock, filesystem, randomness, third-party SDK → replace, because they
make the test slow or non-deterministic. Your own domain objects, pure
functions, value types → never: a mock of your own code asserts that your code
calls itself in a particular way, which is mechanism, not behaviour.
Rule of thumb: if swapping a collaborator for a mock changes *what the test
proves*, you are mocking too deep.
Prefer the narrowest double that works. A stub returning a value beats a mock
verifying call order — `verify(x.wasCalledOnce())` is a claim about how, and
how is free to change. Assert on the observable result instead.
## Anti-patterns
- **Implementation-coupled** — asserts on internals, private methods, or reads
state through a side channel (querying the database instead of going through
the interface). Tell: a refactor breaks it while behaviour is unchanged.
- **Tautological** — the assertion recomputes the expectation the way the code
does, so it passes by construction.
- **Horizontal slicing** — all the tests first, then all the implementation.
Bulk tests verify *imagined* behaviour: they commit to a shape before the
implementation has taught you anything, and they go insensitive to the changes
that matter. Work vertically — one test, one implementation, repeat.
- **Assertionless** — exercises the code and asserts nothing, or asserts
something true regardless (`expect(result).toBeDefined()`).
- **Conditional** — an `if` in a test body is either two tests, or a test that
quietly skips its own assertion for some inputs.
- **Shared mutable fixture** — order-dependent suites that pass alone and fail
in CI. Each test builds what it needs.
## Excuses that sound like reasons
Each feels true in the moment; none changes what the test is for. When one is
the reason you are about to skip a step, write the test instead.
- *"Too simple to test."* Simple code still breaks, and its test is the cheapest.
- *"I'll add the test after."* A test written after passes on its first run, so
it never proved it can fail.
- *"I checked it by hand."* A manual check leaves nothing behind; the next change
undoes it unseen.
- *"The test is wrong, not the code."* Maybe. Then it is a breaking change: agree
it first, never edit the assertion to reach green.
- *"It's urgent."* An urgent fix that breaks again is two incidents; the red test
costs minutes.
- *"The user said to skip it."* Not an option lexi offers: the test comes first
whoever asks.
## Coverage
Coverage reports what was executed, never what was verified: a line run by a
test with no assertion counts as covered and is not tested. Use it to find blind
spots. Never set it as a target — a number chased directly gets met with
assertionless tests, and then the suite is worse than before *and* looks better.
## When not to write one
Test the branch, the parser, the boundary, the money path, the thing you had to
think about. Skip the one-line delegation, the generated code, the constant, the
getter.
A suite that tests everything is a suite nobody maintains, and an unmaintained
test that fails gets deleted rather than fixed. Fewer tests that genuinely bite
beat a wall of them that nobody trusts.