Overview
Test-driven development (TDD) writes the test before the code, one small behavior at a time. Martin Fowler's short form of the loop (fowler-tdd (opens in new window)):
Kent Beck's Canon TDD (opens in new window) adds the step most people skip: start from a written list of test scenarios and turn "exactly one item on the list into an actual, concrete, runnable test" at a time.
For a coding agent, the loop is also the proof that a change works. Anthropic's Claude Code best practices (opens in new window) put it as "Give Claude a check it can run" and "Have Claude show evidence rather than asserting success: the test output". A test that was seen failing and then seen passing is that evidence. A test written after the code and only ever seen passing is not: it may pass for the wrong reason.
When to use
Not a fit: throwaway spikes (delete them, then test-drive the real code), pure layout or styling (use a visual check), or a project with no way to run tests yet. In that last case, set up the runner first and say so; don't skip the loop silently.
Neighbors: test-case-ideation-from-story (qa-process) and boundary-value-generator (qa-test-data) help fill the test list in Step 2; the per-language unit-test skills (python-unit-tests, js-unit-tests, jvm-unit-tests, go-unit-tests, dotnet-unit-tests) cover the frameworks themselves; test-code-conventions (qa-test-review) reviews finished tests. This skill owns the order of work and the evidence.
Step 1 - Find the test command and run the suite once
Before changing anything, find how this project runs tests (README, package.json scripts, pyproject.toml, Makefile, CI workflow) and run the existing suite. Write down the result.
This baseline matters later: if three tests were already red before you started, they are not yours, and you must not "fix" them by editing them to pass. Say they were red at the start.
Commands to run one test, which the loop below uses constantly:
| Stack | Run one file or test | Source |
|---|---|---|
| pytest | pytest tests/test_mod.py::test_func | pytest usage (opens in new window) |
| Vitest | npx vitest run foobar -t "name pattern" | vitest cli (opens in new window) |
| Jest | npx jest path/to/my-test.js -t "name pattern" | jest cli (opens in new window) |
| Go | go test ./pkg/... -run 'TestParse$' -count=1 | go help testflag; go test (opens in new window) |
| Maven Surefire | mvn -Dtest=TestCircle#mytest test | surefire single test (opens in new window) |
| Gradle | ./gradlew test --tests SomeTestClass.someSpecificMethod | gradle testing (opens in new window) |
| .NET | dotnet test --filter "FullyQualifiedName~MyTestsClass" | dotnet filter (opens in new window) |
| Rust | cargo test name_filter | cargo test (opens in new window) |
Per go test (opens in new window), "The idiomatic way to disable test caching explicitly is to use -count=1", which matters when you need to see a test actually run red.
Step 2 - Write the test list
Per Canon TDD (opens in new window), step 1 is "Write a list of the test scenarios you want to cover". Write it in plain words, in your reply or a scratch note, before any test code:
parse_duration
- [ ] "90s" -> 90 seconds
- [ ] "1h30m" -> 5400 seconds (units combine)
- [ ] "" -> ValueError
- [ ] "5x" (unknown unit) -> ValueError naming the unit
- [ ] "-5m" -> ValueError (negative not allowed per spec)Include the error cases and edges the request implies, not only the happy path. Do not turn the whole list into test code now: Canon TDD calls converting "all the items on the Test List into concrete tests" up front a mistake, because the first passing test usually changes what the rest should look like.
Step 3 - Red: one test, run it, read the failure
Turn exactly one item into a test. Run only that test (Step 1 table) and read the output before writing any production code.
The test must fail, and fail for the right reason:
| What the run shows | Right red? | Next move |
|---|---|---|
Assertion fails: expected 5400, got 90, or the function raises the wrong error | Yes | Go to Step 4. |
ImportError / cannot find module / undefined: ParseDuration for the new function | Yes, for a brand-new function | Add an empty stub (raise "not implemented" or return a wrong value), re-run, and confirm the failure is now an assertion. |
| Syntax error, typo in a fixture name, wrong file path, test not collected ("no tests ran") | No | Fix the test until it runs and fails on its assertion. |
| The test passes | No | The behavior already exists or the test doesn't check it. Read the test again, tighten the assertion, or drop the item. |
A test you never saw fail proves nothing. If you only see it pass after the change, you can't tell a real fix from an assertion that can't fail.
Step 4 - Green: smallest change, then the whole suite
Change production code until the one test passes. Per Canon TDD: "Change the code to make the test (& all previous tests) pass (adding items to the list as you discover them)".
Rules while going green:
Step 5 - Refactor while green
With every test green, improve names, remove duplication, extract helpers, in both the new code and the code around it (fowler-tdd (opens in new window): "Refactor both new and old code"). Fowler: "The most common way that I hear to screw up TDD is neglecting the third step."
Run the whole suite after the refactor. Two limits from Canon TDD: don't mix "refactoring into making the test pass", and don't refactor "further than necessary for this session".
Then tick the item and go back to Step 3 with the next one, until the list is empty.
Step 6 - Bug fix: reproduce first
For a reported bug, the first test is the reproduction:
The Claude Code best practices give the same order for symptoms: "write a failing test that reproduces the issue, then fix it" (cc-bp (opens in new window)).
Step 7 - Prove it before calling it done
Finish with evidence, not adjectives. The final message states:
**Tests**
- Red: `pytest tests/test_duration.py::test_combined_units` failed with
`AssertionError: assert 90 == 5400` before the change.
- Green: same test passes after the change.
- Suite: `pytest` -> 47 passed (was 42 passed before; 5 new tests).
- Not verified: behavior on Windows paths (no Windows runner here).Worked example
Request: "Add parse_duration to timeutil.py that accepts strings like 90s, 5m, 1h30m and returns seconds."
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Writing the code first, then tests that pass on the first run | Nothing shows the tests can fail; they may assert nothing useful | Red first (Step 3) |
| Writing every test up front, then making them pass | Canon TDD calls this out; the first design choice reshapes the rest | One item at a time (Step 2) |
| Editing expected values to what the code returns | Locks the bug into the test | Fix the code, or state that the test was wrong (Step 4) |
| Deleting or loosening assertions to get green | The suite passes and proves nothing ("Make it pass for real") | Step 4 rules |
| Tests with no assertions "for coverage" | Canon TDD: "write tests without assertions just to get code coverage" is a mistake | Every test asserts the behavior on its list item |
| Running only the new test before claiming done | Breaks elsewhere stay hidden | Whole suite in Steps 4 and 5 |
| "Fixed, should work now" with no output | The reader can't tell what ran | Evidence block (Step 7) |
| Refactoring while a test is red | Two changes at once; a failure can't be attributed | Refactor only on green (Step 5) |
Limitations
References
- Discount codes
- Split bill loses a cent