TDD
Overview
Write the test first. Watch it fail. Write the minimal code to pass. Refactor.
Core principle: if you didn't watch the test fail, you don't know it tests the right thing.
REQUIRED COMPANION: use test-design for what to feed, assert, and mock. This skill is only the rhythm. Formalistic RED tests are still forbidden — a failing theater test proves nothing.
When to Use
Always: new features, bug fixes, refactoring, behavior changes.
Exceptions (ask the human first): throwaway prototypes, generated code, pure config with no behavior.
Thinking "skip TDD just this once"? Stop — that's rationalization.
The Iron Law (non-negotiable)
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
Wrote code before the test? Delete it. Start over.
No exceptions:
- Don't keep it as "reference"
- Don't "adapt" it while writing tests
- Don't look at it
- Delete means delete
Violating the letter is violating the spirit.
Workflow (when invoked)
Announce "Using tdd to …", then one todo per cycle step:
- RED — one failing behavior test (apply test-design: contract, narrow asserts, hand-written
expected) - Verify RED — run it; confirm fail for the right reason (missing feature, not typo/error)
- GREEN — minimal production code to pass
- Verify GREEN — run it; all relevant tests pass; output pristine
- REFACTOR — clean up; stay green; no new behavior
- Repeat — next behavior → next RED
Bug found later? Write a failing reproduction test first, then the same cycle.
Red → Green → Refactor
RED — write one failing test
One behavior. Clear name (role + scenario + expectation). Real collaborators unless IO forces a Fake/Recording (see test-design).
test("retries failed operations 3 times", async () => {
let attempts = 0
const operation = () => {
attempts++
if (attempts < 3) throw new Error("fail")
return "success"
}
expect(await retryOperation(operation)).toBe("success")
expect(attempts).toBe(3)
})
Bad: vague name, asserts only on mock call counts, or expected computed with production logic.
Verify RED — mandatory
Run the targeted test. Confirm:
- Fails (not errors out from typos/imports)
- Failure message matches the missing behavior
- If it passes immediately → you're testing existing behavior or asserting nothing real — fix the test
- If it errors → fix the harness, re-run until it fails correctly
GREEN — minimal code
Simplest code that passes. No extra features, no drive-by refactors, no YAGNI options objects.
Verify GREEN — mandatory
Run again. All related tests pass. No new warnings/errors ignored.
REFACTOR
Only after green: names, duplication, helpers. Keep tests green. Don't add behavior (new behavior → new RED).
Good vs bad tests (rhythm lens)
| Good | Bad | |
|---|---|---|
| Size | One behavior; "and" in the name → split | Kitchen-sink test |
| Name | Describes observable behavior | test1 / works |
| Proof | You saw RED for the right reason | Passed on first run |
| Depth | Asserts contract (test-design) | Only toHaveBeenCalled / coverage lighting |
Rationalization Table
| Excuse | Reality |
|---|---|
| "I'll test after to verify" | Passes immediately prove nothing. You never saw it catch the bug. |
| "Tests after achieve the same goals" | After = "what does this do?" First = "what should this do?" |
| "Already manually tested" | Ad-hoc, no record, can't re-run. |
| "Too simple to test" | Simple code breaks. A focused test is cheap. |
| "Deleting X hours is wasteful" | Sunk cost. Unverified code is debt. Delete and restart. |
| "Keep as reference / adapt while testing" | That's testing after. Delete means delete. |
| "Need to explore first" | Explore, then throw away exploration; start TDD clean. |
| "TDD slows me down" | Debugging in prod is slower. Pragmatic = test-first. |
| "Hard to test" | Design smell. Simplify the interface (test-design § hard-to-test). |
| "Existing code has no tests" | You're changing it — add the failing test for the behavior you touch. |
| "Just this once / spirit not ritual" | Letter = spirit. No exceptions without the human. |
Red Flags — delete code and restart
- Production code before a failing test
- Test written after implementation
- Test passes on first run
- Can't explain why RED failed
--no-verify/ skipping the test run "to save time"- Keeping pre-test code as "reference"
- "Pragmatic shortcut" / "this is different because…"
Any hit → delete the production code. Start over at RED.
Anti-patterns (quick)
- Testing mock behavior instead of the unit
- Test-only methods on production types
- Mocking a pile of neighbors (usually glue → prefer integration, or redesign — test-design §1/§5)
Checklist (per behavior)
- Worth a real test? (test-design ROI) — else don't fake TDD on glue
- RED written from contract; watched fail for the right reason
- GREEN is minimal; verify pass
- REFACTOR stays green; no new behavior smuggled in
- Mocks only for external world; asserts are narrow and hard
Hand-off
- Case quality unclear → test-design
- Boundaries / mock piles → solid
- Feature shape unclear → grill → write-spec first, then TDD
- Ready to ship → commit-and-push