tdd

Test-driven development via red-green-refactor with an auditable commit protocol. Use when implementing any feature, bugfix, refactor, or behavior change before writing implementation code, or when the user mentions TDD, red-green-refactor, or test-first development.

pandoscope/skills1.2k installsMITSynced Aug 26

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: tdd
description: Test-driven development via red-green-refactor with an auditable commit protocol. Use when implementing any feature, bugfix, refactor, or behavior change before writing implementation code, or when the user mentions TDD, red-green-refactor, or test-first development.
license: MIT
---

# Test-Driven Development

Write test first. Commit it red. Write minimal code to pass. Commit green. Refactor.

**Core principle:** tests verify behavior through public interfaces, not implementation. Code can change entirely; tests shouldn't. A test that breaks when you rename an internal function — with no behavior change — was testing implementation. Delete it.

No watched failure = no proof the test tests the right thing. Commit history is the evidence; hooks check its structure.

See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines.

## Iron Law

NO PRODUCTION CODE WITHOUT A FAILING TEST COMMITTED FIRST.

Wrote code before the test? Delete it. Implement fresh from tests. Delete means delete. Exceptions (throwaway prototypes, generated code, config) need human sign-off. Thinking "skip TDD just this once"? That is rationalization.

## 1. Plan (before any code)

Use the project's domain glossary so test names and interface vocabulary match the codebase; respect ADRs in the area you touch.

- List the *behaviors* to test, not implementation steps. Prioritize critical paths and complex logic — you can't test everything.
- Design interfaces for [testability](interface-design.md); identify opportunities for [deep modules](deep-modules.md) (small interface, deep implementation)
- Confirm the public interface and the priority behaviors with the user, then proceed.

## 2. Vertical slices, not horizontal

DO NOT write all tests, then all implementation. That is horizontal slicing and it produces crap tests: written in bulk they test *imagined* behavior and the *shape* of things (signatures, data structures), go insensitive to real changes, and commit you to test structure before you understand the code.

Work in vertical slices — one test → one implementation → repeat. Each test responds to what the last cycle taught you.

```text
WRONG (horizontal):  RED: t1 t2 t3 t4   GREEN: i1 i2 i3 i4
RIGHT (vertical):    t1→i1  t2→i2  t3→i3 ...
```

The first slice is a tracer bullet: it proves the path works end to end.

## 3. Commit protocol

One behavior = one RED commit + one GREEN commit. Multiple cycles per branch is fine; prefer one cycle in flight in Go/Rust repos (see markers).

**Every commit — RED included — leaves the tree lint-clean.** After each
commit, run the repo's full hook suite over the whole tree (e.g.
`prek run --all-files`): exit code 0 and no files modified. An auto-fixer
changing files IS a failure — apply the fix and amend it into the commit that
introduced the problem before starting the next cycle. CI evaluating only the
PR head never excuses a dirty intermediate commit; per-commit hygiene is
verified here, at commit time, or not at all.

### STUB commit (only when the RED test needs a missing API surface)

A RED commit is red on **tests only**: lint, format, and type checks still
pass on it. When the new test calls a function, method, or parameter that does
not exist yet, type checking would fail — so first commit a minimal
signature-only stub as its own commit, prefix `chore(stub):`. Signature +
docstring, zero behavior: a new callable raises `NotImplementedError`; a new
parameter is accepted and ignored. This is the one sanctioned exception to the
Iron Law: a stub defines *interface*, and must contain no *implementation* —
any logic in a stub commit is production code without a failing test. Skip
this step entirely when the surface already exists.

### RED commit

- Write one failing test. One behavior, clear name, real code — no mocks unless unavoidable.
- Run it *unmarked*; watch it fail for the **right reason** (feature missing — behavior wrong, assertion fails, or the stub's `NotImplementedError` — never a typo, import error, or missing symbol). Passes immediately? It tests existing behavior — fix the test.
- Add the marker (below), suite green, commit. Tests only, prefix `test(red):`.

| Language | Marker | Strict? |
| --- | --- | --- |
| Python (pytest) | `@pytest.mark.xfail(strict=True)` | yes — XPASS fails suite |
| TS/JS (vitest) | `test.fails(...)` / `it.fails(...)` | yes |
| TS/JS (jest) | `it.failing(...)` | yes |
| Go | `//go:build red` + `TestRed` prefix + `red-tests` job | aggregate only |
| Rust | `#[ignore = "red"]` + `red_` prefix + `red-tests` job | aggregate only |
| Other | find a strict expected-failure mechanism; none exists → commit unmarked, tell the human the repo lacks red enforcement | n/a |

Go/Rust build-tag/ignore markers *exclude* tests from the normal suite, so a `red-tests` CI job must run only the marked tests and expect failure (no-op when none exist). Its exit code is aggregate: two red tests in flight, one wrongly passing → the job still passes. Strict markers catch this per-test; the job doesn't.

### GREEN commit

- Simplest code that passes. YAGNI — no speculative generality.
- Remove this cycle's red markers. No other test changes in this commit.
- Full suite green, output pristine. Fails? Fix the code, not the test. Prefix `feat:`/`fix:`.

### REFACTOR (after green only)

Remove duplication, improve names, extract helpers, deepen modules. Tests stay green, no new behavior, separate commit. **Never refactor while red.** Then start the next cycle.

After all tests pass, look for [refactor candidates](refactoring.md):

## Enforcement — and its limits

`lint-red.sh` (ships with skill; wire into prek + CI): `staged`/`commit` modes check that `test(red):` commits touch only test files and add a marker, others add none; `merge` mode rejects any markers in tree; if Go/Rust markers are present, a `red-tests` job must exist.

Hooks verify commit *structure* and marker hygiene. They do **not** verify you ran the unmarked test and watched it fail for the right reason — that stays on you. Same for per-commit lint: an installed pre-commit hook blocks dirty commits at commit time, but where no hook is installed (fresh remote environments), the after-every-commit `--all-files` run is manual discipline — do it anyway. Strict markers partially compensate (XPASS catches tests of already-existing behavior). "Lint passed" ≠ "TDD verified."

## Good tests

| Quality | Rule |
| --- | --- |
| Behavioral | Exercises a real path through the public API; survives refactors |
| Minimal | One thing. "and" in the name? Split it. |
| Clear | Name states the behavior, not `test1` |
| Honest | Tests the code, never the mock |

```ts
// Good — tests real behavior (vitest; jest: it.failing)
test.fails('retries failed operations 3 times', async () => {
  let attempts = 0;
  const op = () => { attempts++; if (attempts < 3) throw new Error('fail'); return 'ok'; };
  expect(await retryOperation(op)).toBe('ok');
  expect(attempts).toBe(3);
});
```

## Red flags — STOP, delete, restart

Unmarked test passes before implementation exists · can't explain why the test failed · weakening an assertion in GREEN to make it pass · testing the mock · "just this once" · "I'm being pragmatic, TDD is dogmatic."

Read [testing-anti-patterns.md](testing-anti-patterns.md) to avoid common pitfalls.

## Rationalization table

| Excuse | Reality |
| --- | --- |
| "Too simple to test" | Simple code breaks. The test takes 30s. |
| "I'll test after" | Tests-after are biased by the implementation: "what does this do?" not "what should this?" |
| "Deleting hours of code is wasteful" | Sunk cost. Unverified code is debt. |
| "Test is hard to write" | Hard to test = hard to use. Listen to it; simplify the interface. |
| "Must mock everything" | Too coupled. Inject dependencies. |
| "Lint passed, TDD done" | Hooks check structure, not that you watched the failure. |

## When stuck / debugging

Don't know how to test → write the wished-for API and assertion first. Found a bug → write a failing test reproducing it, then run the full protocol. Never fix a bug without a test.

More Refactoring skills

← All Refactoring skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY