Red-green-refactor discipline: write the failing test first, make it pass with minimal code, then clean up.
What it does
The normal order is to build something and then, if there is time, write tests for it. Tests written that way pass the moment you run them, which feels reassuring and proves very little, because nobody ever saw them catch anything.
With this skill active, the assistant reverses the order. It writes one small test describing what should happen, runs it, and shows you it failing for the right reason. Only then does it write code, and only enough to make that test pass. Then it tidies up while the test keeps it honest, and repeats. For a bug, the first step is a test that reproduces the report, so there is proof the fix actually fixed something.
You end up with working code and a set of tests that have each been seen to fail once, which is the difference between a safety net and a decoration.
You end up with the feature working and a test for each behaviour that was seen to fail before it passed.
When to reach for it
Use it for any feature or bugfix where whether it works can be checked by running something. It pays for itself fastest on rules with edge cases (pricing, permissions, validation, dates) and on bugs that have come back before.
Skip it for throwaway scripts, one-off data fixes, and configuration files. Be honest about which category the work is really in, because "just this once" is how the habit dies.
---
name: test-driven-development
description: Drives a feature or bugfix with the red-green-refactor loop. Write one failing test that captures the requirement, watch it fail for the right reason, write the least code that makes it pass, then clean up while it stays green. Use before writing implementation code for a feature, and before fixing any reported bug.
---
# Test-Driven Development
You are the engineer who writes the test first and watches it fail. The failing test is what proves the test works; code written before it has nothing holding it honest.
**Core principle:** if you did not watch the test fail, you do not know that it tests anything.
## Workflow
1. **Pick one slice of behaviour.** The smallest piece of the requirement that can be stated as an assertion.
2. **Write one test** that asserts it. Name it after the behaviour, not the function.
3. **Run it and watch it fail.** Read the failure message. It must fail because the behaviour is missing, not because of a typo or a missing import.
4. **Write the least code that makes it pass.** Nothing beyond what this test demands.
5. **Run it and watch it pass**, along with everything else.
6. **Refactor while green.** Names, duplication, structure. Run again after each step.
7. **Repeat** for the next slice until the requirement is covered, then run the whole suite.
```text
Cycle checklist
[ ] One behaviour, one test, named as a requirement
[ ] Test run before implementation and seen to FAIL
[ ] Failure message is the expected one, not a typo or import error
[ ] Implementation is the minimum that satisfies the test
[ ] Test run again and seen to PASS
[ ] Neighbouring tests still pass, output clean
[ ] Refactor done while green, tests rerun after
[ ] Full suite green before calling the work done
```
## Red: write the failing test
One test, one behaviour. If the name needs the word "and", it is two tests.
```python
def test_retries_a_failed_request_three_times():
attempts = []
def flaky():
attempts.append(1)
if len(attempts) < 3:
raise ConnectionError("reset")
return "ok"
assert call_with_retry(flaky) == "ok"
assert len(attempts) == 3
```
The test says what should happen, in the language of the requirement. It uses real code rather than a mock of the thing under test, so it can actually catch a mistake.
## Verify red: watch it fail
Not optional, and not something to assume.
```
$ pytest tests/test_retry.py::test_retries_a_failed_request_three_times
E NameError: name 'call_with_retry' is not defined
```
Read the message before continuing:
- **Fails with the expected assertion or a missing symbol.** Good, continue.
- **Passes immediately.** The behaviour already exists, or the test asserts nothing. Find out which before writing any code.
- **Errors for an unrelated reason.** A typo, a bad import, a broken fixture. Fix that and rerun until it fails for the right reason.
## Green: the least code that passes
```python
def call_with_retry(fn, attempts=3):
for attempt in range(1, attempts + 1):
try:
return fn()
except ConnectionError:
if attempt == attempts:
raise
```
No backoff, no jitter, no configurable policy, no logging hooks. The current test does not ask for them. If the next requirement does, the next test will drive it.
## Verify green: watch it pass
```
$ pytest tests/test_retry.py -q
2 passed
```
Confirm three things: the new test passes, the neighbours still pass, and the output is clean, with no new warnings and no stack traces printed on the way through.
If the test still fails, fix the code. Do not adjust the assertion to match what the code happens to do.
## Refactor: clean up while green
Only now. Improve names, remove duplication, extract a helper if two places genuinely need it. Rerun after each change. Behaviour does not change during a refactor; if it does, that is a new test first.
## Bug fixes
A bug report is a missing test, so write it before the fix:
1. Turn the report into a test that reproduces the failure.
2. Run it. It must fail, and the failure must look like the report.
3. Fix the cause.
4. Run it. It passes.
5. Keep the test. It is now the regression guard.
A fix without a failing test first is a fix nobody can prove. Confirm the test fails against the unfixed code. A regression test that passes both ways is decoration.
## Example
**Requirement.** `slugify(" Hello World! ")` returns `"hello-world"`.
| Step | What happened |
| --- | --- |
| Red | `assert slugify(" Hello World! ") == "hello-world"` fails, `slugify` undefined. |
| Green | Lowercase, strip, collapse whitespace to hyphens, drop punctuation. Passes. |
| Red | Next slice: `slugify("Café")` should not lose the accent handling. Fails. |
| Green | Normalise unicode before stripping. Both tests pass. |
| Refactor | Extract the punctuation set to a module constant. Rerun and both still pass. |
Two cycles, two tests, and an implementation that never grew a feature nobody asked for.
## Rules
- No implementation code without a failing test for it. If code was written first, delete it and drive it from a test rather than adapting it. Adapted code is tested after, whatever the order of the files says.
- One behaviour per test. Split anything whose name contains "and".
- Test names read like requirements: `test_rejects_an_empty_email`, not `test_validation_2`.
- Watch every test fail before you make it pass.
- Never weaken an assertion to get to green. If the test is genuinely wrong, fix the test as its own change with its own reasoning.
- Assert on behaviour and observable output, not on internal calls. A test that asserts a mock was called proves the mock works.
- Use real objects unless a dependency is slow, non-deterministic, or costs money.
- Keep the test output clean. Noise is where real failures hide.
## Edge cases
- **You cannot see how to test it.** Write the test you wish you could (the API you would want to call) and let the difficulty tell you the design is wrong. Hard to test almost always means hard to use.
- **The test needs enormous setup.** That is a design signal too. Extract the piece that has the actual logic and test it directly.
- **Exploratory work where you do not know the shape yet.** Explore freely, then throw the exploration away and rebuild it test-first. Keeping the exploration is how tests-after gets rationalised.
- **Legacy code with no tests.** Write a characterisation test that captures what it does today, confirm it passes, then change behaviour test-first from there.
- **The behaviour is not deterministic.** Time, randomness, network. Inject the clock, seed the random source, or stub the boundary. If none of that is possible, test the layer underneath it.
- **A framework or configuration file.** Not everything is testable and pretending otherwise wastes time. Test the code you wrote, not the framework's.
- **Truly throwaway code.** A one-off script that runs once and is deleted does not need the loop. Be honest about which category the work is really in.
## Files
- `references/examples.md` - three complete cycles with real test output at each step: a new feature over two red-green rounds, a bug fix driven from the report, and a case where the first test passed immediately and what that revealed. Read it for the rhythm and for how much code "minimal" really means.
- `references/anti-patterns.md` - the tests that look thorough and catch nothing: asserting on mocks, test-only methods on production classes, over-mocked suites, and assertions that pass whatever the code does. Read it whenever you are about to add a mock or a test helper.
references/examples.md
# Worked cycles
Three complete runs through red-green-refactor with the actual command output at each step. The third one is the most instructive: the first test passed immediately, which is a finding, not a success.
## Contents
- [Cycle 1: a new feature over two rounds](#cycle-1-a-new-feature-over-two-rounds)
- [Cycle 2: a bug fix driven from the report](#cycle-2-a-bug-fix-driven-from-the-report)
- [Cycle 3: the test that passed immediately](#cycle-3-the-test-that-passed-immediately)
- [What the three cycles share](#what-the-three-cycles-share)
---
## Cycle 1: a new feature over two rounds
**Requirement.** Discount codes: a valid code reduces the order total by its
percentage; an expired code is rejected with a clear error.
### Round 1, red
One behaviour: a valid code reduces the total.
```python
def test_valid_code_reduces_the_total_by_its_percentage():
order = Order(subtotal=Decimal("200.00"))
code = DiscountCode(code="SAVE10", percent=10, expires_on=date(2030, 1, 1))
total = apply_discount(order, code, today=date(2026, 6, 1))
assert total == Decimal("180.00")
```
```
$ pytest tests/test_discount.py -q
E ImportError: cannot import name 'apply_discount' from 'billing.discount'
1 error
```
Fails for the right reason: the function does not exist.
### Round 1, green
```python
def apply_discount(order, code, today):
reduction = order.subtotal * Decimal(code.percent) / Decimal(100)
return order.subtotal - reduction
```
```
$ pytest tests/test_discount.py -q
1 passed
```
Note what is missing: no expiry check. The current test does not ask for one, and
inventing it here would mean shipping untested behaviour. The next test drives it.
### Round 2, red
```python
def test_expired_code_is_rejected():
order = Order(subtotal=Decimal("200.00"))
code = DiscountCode(code="OLD20", percent=20, expires_on=date(2026, 1, 1))
with pytest.raises(DiscountExpired) as exc:
apply_discount(order, code, today=date(2026, 6, 1))
assert "OLD20" in str(exc.value)
```
```
$ pytest tests/test_discount.py -q
E Failed: DID NOT RAISE <class 'billing.discount.DiscountExpired'>
1 failed, 1 passed
```
The failure message is exactly the one predicted, which is the confirmation that
matters. A test that failed with an import error here would have meant the test
file was broken, not the code.
### Round 2, green
```python
def apply_discount(order, code, today):
if today > code.expires_on:
raise DiscountExpired(f"{code.code} expired on {code.expires_on}")
reduction = order.subtotal * Decimal(code.percent) / Decimal(100)
return order.subtotal - reduction
```
```
$ pytest tests/test_discount.py -q
2 passed
```
### Refactor
The rounding rule belongs with the money type, not scattered in the discount
function. Extracted `Money.percent_of()` and reran after the change:
```
$ pytest tests/ -q
128 passed
```
Green before, green after, no behaviour change. That is what makes the refactor safe.
---
## Cycle 2: a bug fix driven from the report
**Report.** "Users with a plus sign in their email address cannot reset their
password. The reset link says the token is invalid."
### Red: reproduce before fixing
The report becomes a test. The temptation is to go and read the token code first;
the test is faster and it produces evidence.
```python
def test_reset_link_works_for_an_address_containing_a_plus():
user = create_user(email="ada+billing@example.com")
token = request_password_reset(user.email)
result = complete_password_reset(token, new_password="hunter2!")
assert result.ok is True
```
```
$ pytest tests/test_reset.py::test_reset_link_works_for_an_address_containing_a_plus
E assert False is True
E + where False = ResetResult(ok=False, error='invalid token').ok
1 failed
```
The failure matches the report. That match is what makes this a reproduction rather
than a guess.
### The cause
The token embeds the email and is placed in a URL. It was decoded with a parser
that treats `+` as an encoded space, so `ada+billing@example.com` came back as
`ada billing@example.com` and matched no user.
### Green
```diff
- email = unquote_plus(raw_email)
+ email = unquote(raw_email)
```
```
$ pytest tests/test_reset.py -q
9 passed
```
### Confirm the test is a real guard
Reverting the fix locally and rerunning is a ten-second check that the test would
catch the bug returning:
```
$ git stash && pytest tests/test_reset.py -q
1 failed, 8 passed
$ git stash pop
```
A regression test that passes with the bug present is decoration. This one fails,
so it is a guard.
### Refactor
Two other call sites used `unquote_plus` on values that came from a path segment
rather than a query string. Both were noted in the pull request description and
left for a separate change, because fixing them here would have made the bug
fix unreviewable.
---
## Cycle 3: the test that passed immediately
**Requirement.** "The API should reject requests with a body larger than 1MB."
### Red: except it was green
```python
def test_rejects_a_body_over_one_megabyte():
body = b"x" * (1_024 * 1_024 + 1)
response = client.post("/api/import", data=body)
assert response.status_code == 413
```
```
$ pytest tests/test_limits.py -q
1 passed
```
Passing on the first run is not good news. There are three possible explanations
and only one of them is fine:
1. The behaviour already exists. Fine, but then the ticket is already done, and
that is worth knowing before writing any code.
2. The test asserts something other than what it appears to. Bad.
3. The test never reaches the code under test. Bad.
### Investigating
The answer here was a mix. The web server in front of the application rejects
bodies over 1MB by default, and the test client happened to go through it. The
application itself had no limit at all, so the same request sent directly to the
application, which is how the internal service calls it, was accepted.
### The corrected test
```python
def test_application_rejects_a_body_over_one_megabyte_without_the_proxy():
body = b"x" * (1_024 * 1_024 + 1)
response = app_client.post("/api/import", data=body) # bypasses the proxy
assert response.status_code == 413
```
```
$ pytest tests/test_limits.py -q
E assert 200 == 413
1 failed
```
Now it fails, and it fails for the reason the ticket describes.
### Green
```python
MAX_IMPORT_BYTES = 1_024 * 1_024
@app.post("/api/import")
def import_data(request):
if request.content_length and request.content_length > MAX_IMPORT_BYTES:
return Response(status=413)
...
```
```
$ pytest tests/test_limits.py -q
2 passed
```
Both tests were kept. The first documents that the proxy also enforces the limit,
which is genuinely useful to know if anyone ever changes the proxy configuration.
---
## What the three cycles share
1. The failure message was read every time, not just the pass/fail result. In cycle 3 that is the only thing that caught a test which was measuring the wrong component.
2. The green step stayed minimal. Cycle 1 shipped a discount function with no expiry check because no test asked for one yet, and the expiry arrived one test later.
3. The bug fix started with a reproduction, and then proved the reproduction was real by reverting the fix and watching the test fail again.
4. Refactoring happened only while green, and only after the behaviour was locked in by a passing test.
5. Adjacent problems (the two other `unquote_plus` call sites) were reported rather than fixed inside the same change.
references/anti-patterns.md
# Testing anti-patterns
Tests that look thorough and catch nothing. Each one below has passed code review
somewhere, which is why they are worth naming.
## Contents
- [Asserting on the mock instead of the behaviour](#asserting-on-the-mock-instead-of-the-behaviour)
- [Mocking the thing under test](#mocking-the-thing-under-test)
- [Test-only methods on production classes](#test-only-methods-on-production-classes)
- [Assertions that cannot fail](#assertions-that-cannot-fail)
- [Testing the framework](#testing-the-framework)
- [One test asserting six behaviours](#one-test-asserting-six-behaviours)
- [Tests that depend on each other](#tests-that-depend-on-each-other)
- [Snapshot tests used as the only assertion](#snapshot-tests-used-as-the-only-assertion)
- [Sleeping instead of waiting for a condition](#sleeping-instead-of-waiting-for-a-condition)
- [Weakening a test to get to green](#weakening-a-test-to-get-to-green)
- [Chasing coverage instead of behaviour](#chasing-coverage-instead-of-behaviour)
## Asserting on the mock instead of the behaviour
```python
def test_sends_welcome_email():
mailer = Mock()
register_user("ada@example.com", mailer=mailer)
mailer.send.assert_called_once()
```
This passes if `send` is called with the wrong address, the wrong template, or a
subject line reading nothing at all. It is a test of the mock library.
Better: assert on something observable. Use a fake mailer that records real
messages, then assert on the message.
```python
def test_sends_welcome_email():
mailer = RecordingMailer()
register_user("ada@example.com", mailer=mailer)
assert mailer.sent[0].to == "ada@example.com"
assert "Welcome" in mailer.sent[0].subject
```
## Mocking the thing under test
If the test mocks the function it is supposed to be checking, it asserts that the
mock returns what it was configured to return. Mock the boundary: the network, the
clock, the payment provider. Never the logic you are verifying.
## Test-only methods on production classes
```python
class OrderService:
def _reset_for_tests(self): # only ever called from tests
self._cache.clear()
```
Production code now carries a method that exists to work around a testing problem,
and nothing stops it being called in production. Fix the seam instead: construct a
fresh instance per test, or inject the cache.
## Assertions that cannot fail
- `assert result is not None` on a function that returns a constructed object.
- `assert len(items) >= 0`.
- A test whose only assertion is that no exception was raised, for code that has no
raise statement in it.
If you cannot describe a code change that would make the assertion fail, the
assertion is not testing anything.
## Testing the framework
Asserting that a web framework routes a URL, that an ORM saves a row, or that a
validation library rejects an empty string. Those are covered by the library's own
suite. Test the behaviour you wrote on top of them.
## One test asserting six behaviours
```python
def test_user_flow():
# 60 lines: register, verify, log in, change password, log out
```
When it fails, the report says "test_user_flow failed" and someone reads 60 lines
to find out which of six behaviours broke. Split it. The setup can be shared
through a fixture.
## Tests that depend on each other
A test that only passes when it runs after another test is a shared-state bug
waiting for a parallel test runner. Every test creates the state it needs and
leaves the world as it found it. If two tests fight over a module-level value,
that value is the bug.
## Snapshot tests used as the only assertion
A snapshot catches "something changed" but never says what should be true. Used
alone, it trains people to regenerate the snapshot whenever it breaks, which
converts every real regression into a one-key fix. Keep snapshots for large output
shapes and add explicit assertions for the parts that carry meaning.
## Sleeping instead of waiting for a condition
```python
time.sleep(2) # let the worker finish
assert job.status == "done"
```
Slow when it works, flaky when the machine is loaded, and silent about which. Poll
for the condition with a timeout, or expose a hook the test can await.
## Weakening a test to get to green
Changing `assert total == 180` to `assert total > 0`, widening a tolerance, or
adding a retry to a test that fails one time in ten. Each converts a real signal
into a decoration. If the assertion is genuinely wrong, change it as its own commit
with its own reasoning, not as part of making a feature pass.
## Chasing coverage instead of behaviour
A suite can reach 95% line coverage while asserting almost nothing, because calling
a line is not the same as checking what it does. Coverage is useful for spotting
whole files nobody tests. It is a poor target and a worse goal.
Install it
Claude Code (this project)
Unzip the download into .claude/skills/. The folder is already named right.
Claude Code (every project)
Unzip into ~/.claude/skills/ to use it everywhere.
claude.ai
Upload the zip under Settings, then Capabilities, then Skills (paid plans).
How to call it
Just describe the task. The description line inside the skill does the matching. Or call it directly:
/test-driven-developmentwrite the test first for thisdo this test-drivenadd a test that reproduces this bug before fixing itwhat test would prove this works?red-green-refactor this change
Works beyond Claude
Skills use the open Agent Skills format. The exact same folder works in OpenAI Codex, Gemini CLI, Cursor, GitHub Copilot and other tools that read the format.
Using a plain chat app? Paste SKILL.md into ChatGPT or Gemini as instructions. You lose auto-triggering, but the method still works.