Module 4: The Complete TDD Workflow
The Implementation Cycle with Claude Code
The Implementation Cycle with Claude Code
Capsule overview
You have a red test. You ask Claude Code to implement it. What context does it need? What do you do when it fails? When do you adjust the prompt and when do you adjust the test? This capsule answers those questions with a concrete framework. It's the most important one in the module: it covers the interaction between you and Claude Code during TDD's green phase.
In classic TDD, you write the test AND the implementation. With Claude Code, you write the test and the AI implements — but sometimes the implementation fails. Sometimes it passes the test but the code is bad. Sometimes Claude Code over-engineers or cheats. This capsule gives you the judgment to handle it.
By the end you'll understand how to give effective context, evaluate implementations, and decide when to intervene vs when to let Claude Code iterate.
The Implementation Prompt
What context Claude Code needs
Claude Code doesn't have access to your intent or to the project's conventions. What it gets is what you give it. To implement correctly, it needs three kinds of information:
1. The test file
Without the test, Claude Code is guessing what it should do. With the test, it has an executable specification.
2. Existing interfaces
If the function will be called by a FastAPI endpoint, or if it must fit into an existing class, Claude Code needs to see that interface.
3. Explicit constraints
"No external libraries", "it must be async", "use only the stdlib", "compatible with Python 3.10". Without constraints, Claude Code may choose dependencies or approaches you don't want.
An effective prompt pattern
Implement [function/class] so it passes these tests: [tests].
Constraints: [restrictions].
Context: [existing interfaces or code].
This pattern is generic. What matters is that the tests, constraints and interfaces are explicitly present.
A good prompt vs a bad prompt
| Aspect | Bad prompt | Good prompt |
|---|---|---|
| Specification | "Implement a token generator" | "Implement generate_token(user_id: str) -> str that passes these tests: [test code]" |
| Tests | Doesn't include tests | Includes the content of the test_*.py file |
| Constraints | None | "No external libraries, stdlib only, minimum length 32 characters" |
| Interfaces | Vague | "This function will be called from auth/login.py, which expects a str" |
| Typical result | Claude guesses, may over-engineer | Claude has clear specs, a bounded implementation |
An example of a bad prompt:
Implement a token system for authentication.
Why it fails: Claude Code could use PyJWT, generate tokens with its own format, use secrets or uuid, include claims you don't need. There's no way to validate whether the implementation is correct beyond "it looks fine".
An example of a good prompt:
Implement the generate_token(user_id: str) -> str function in auth/tokens.py
so it passes these tests (they're in tests/test_tokens.py):
def test_generate_token_returns_string():
token = generate_token("user-123")
assert isinstance(token, str)
assert len(token) >= 32
def test_generate_token_different_each_time():
t1 = generate_token("user-123")
t2 = generate_token("user-123")
assert t1 != t2
Constraints:
- Python stdlib only (no PyJWT or other dependencies)
- The function must be pure (the same input doesn't guarantee the same output, for security)
Why it works: Executable tests, clear constraints, an explicit target file. Claude Code knows exactly what to implement and what not to.
Evaluating Claude Code's Implementation
Running the tests isn't enough. You need three checks:
1. Do the tests pass?
pytest tests/test_tokens.py -v
If they fail, the decision framework kicks in (next section). If they pass, continue.
2. Is the code correct AND good?
Passing tests doesn't guarantee a good implementation. Check:
- Logic: Does it solve the problem genuinely or does it cheat?
- Edge cases: Does it handle empty inputs, None, very long strings?
- Style: Does it follow the project's conventions?
3. Warning signs
Over-engineering: Claude Code sometimes adds features the tests don't ask for.
# The test asks for: return a string of at least 32 characters
# Claude implements: a TokenManager class with refresh, expire, claims, etc.
If the test only asks for a simple function, the implementation must be simple.
Shortcuts / hardcoding: Claude Code sometimes cheats to pass the test.
# Test: assert generate_token("user-123") != generate_token("user-123")
# Claude implements:
def generate_token(user_id: str) -> str:
return str(uuid.uuid4()) # OK, it passes the test
# But another test: assert "user-123" in generate_token("user-123")
# Claude could cheat:
def generate_token(user_id: str) -> str:
return f"user-123-{uuid.uuid4()}" # Hardcodes user_id into the output
The second implementation passes a specific test but doesn't meet the real requirement (a token that contains the user_id in a way that's usable for validation). Detecting these shortcuts requires reading the code with a critical eye.
A test that detects hardcoding: Use @pytest.mark.parametrize with several inputs. If Claude hardcodes for a single case, it will fail with others:
@pytest.mark.parametrize("user_id", ["user-1", "user-2", "admin-99"])
def test_token_contains_user_id(user_id):
token = generate_token(user_id)
assert user_id in token
A hardcoded return "user-123-abc" would fail for "user-2" and "admin-99".
When Claude Code Fails the Tests: A Decision Framework
This is the module's key skill. When a test fails after Claude Code implements, what do you do?
A test fails after Claude implements
│
├── Is the test correct?
│ │
│ ├── YES → An implementation problem
│ │ ├── Lack of context → Add more context to the prompt
│ │ ├── Wrong approach → Suggest another approach
│ │ └── An edge case → Let Claude Code iterate
│ │
│ └── NO → A spec problem
│ ├── An ambiguous test → Clarify the test
│ ├── An over-specified test → Simplify the test
│ └── A missing constraint → Add the constraint
The guiding question: "What am I trying to specify?"
- If the test correctly captures your intent → the problem is in the implementation. Give more context, suggest an approach, or let it iterate.
- If reading the test makes you realize it doesn't express what you want → the problem is in the spec. Adjust the test.
Examples of applying it
Case 1: Lack of context
The test fails: generate_token("") should raise ValueError, but Claude implemented a function that accepts empty strings.
Diagnosis: The test is correct (we don't want tokens for an empty user_id). Claude didn't have that requirement explicitly.
Action: Add to the prompt: "It must raise ValueError if user_id is empty or only whitespace."
Case 2: An ambiguous test
Test: assert len(generate_token("user-1")) >= 10. Claude returns a 10-character string that's always the same for "user-1".
Diagnosis: The test didn't specify that it must be random or unique. The implementation "passes" but doesn't meet the real requirement.
Action: Clarify the test: add test_generate_token_different_each_time or make the assert more specific.
Case 3: Let it iterate
The test fails with AssertionError: assert 31 >= 32. Claude generated 31-character tokens.
Diagnosis: The test is correct, the implementation is almost correct. A minor error.
Action: "The test fails because the token has 31 characters. It must have at least 32." Claude Code usually fixes this in one iteration.
Case 4: An over-specified test
Test: assert generate_token("x")[:1] == "x". It fails because the token has the format user_id:nonce:signature and for user_id "x" the token could be x:abc:def, so [:1] gives "x". But if the format is nonce:user_id:sign, then [:1] could be "a" (from the nonce).
Diagnosis: The test assumes a specific field order. It's fragile and over-specified.
Action: Rewrite the test to verify behavior, not the internal format: assert "x" in generate_token("x") instead of assuming the exact position.
Effective iteration messages
When you delegate the fix to Claude Code, the message matters:
| A vague message | An effective message |
|---|---|
| "It doesn't work" | "Test X fails with AssertionError: [paste the output]. The expected value is Y but you got Z." |
| "Fix the token" | "generate_token returns 31 characters. The test requires >= 32. Change the length of the nonce or the signature." |
| "There's a bug" | "test_parse_config_empty_raises fails: the function doesn't raise ValueError for an empty string. Add that validation." |
Including pytest's exact output (traceback and values) speeds up the iterations.
Giving Effective Context
Include the test AND existing code
Don't just give the test in the prompt. If there are modules that will call this function, include them. That way Claude Code sees the expected signature and the flow.
# auth/login.py (existing)
from auth.tokens import generate_token
def login(user_id: str, password: str) -> dict:
# ... validation ...
token = generate_token(user_id) # <- Claude sees how it's used
return {"token": token, "user_id": user_id}
Specify constraints
Useful examples:
- "Don't use external libraries"
- "It must be async"
- "Compatible with Python 3.10"
- "Maximum 50 lines"
- "The minimal implementation that passes the tests"
Specify interfaces
"This function will be called from the POST /login endpoint, which expects a token in a JWT-like format (header.payload.signature)".
Example: the same prompt with little vs a lot of context
Little context:
Implement generate_token so it passes test_tokens.py
A lot of context:
Implement generate_token(user_id: str) -> str in auth/tokens.py.
Test file (tests/test_tokens.py):
[the complete paste of the file]
The POST /login endpoint in auth/routes.py calls this function and returns
the token to the client. The token must be a string that allows the user
to be identified in subsequent requests.
Constraints:
- Python stdlib only
- Don't use PyJWT (we'll add it later)
- An empty or None user_id must raise ValueError
- Minimum length of 32 characters for basic security
The second prompt produces more bounded and correct implementations.
A checklist before sending the prompt
Before asking for an implementation, verify:
- ✅ You included the complete content of the test file (or the path, if Claude has access to the repo)
- ✅ You specified the target file or module where the code should go
- ✅ You listed the constraints (libraries, style, limits)
- ✅ You included the relevant existing code (calls, interfaces, imports)
- ✅ The test is red (failing) before asking for the implementation
If any of this is missing, the probability of Claude Code getting it right the first time goes down.
The Iteration Loop
Write a test → Prompt Claude → Do the tests fail?
│
├── YES → Evaluate why
│ ├── Add context → Re-prompt
│ ├── Fix the test → Re-run
│ └── Let it iterate → "Fix the failing test: [error message]"
│
└── NO → All green → Refactor? → Next test
The cycle is short: minutes per iteration. If you've spent 15 minutes on the same test, something is wrong: either the prompt is insufficient, or the test is too complex.
Signs that you should change strategy
- More than 3 iterations without success: The prompt isn't working. Try splitting the test into smaller parts or giving an example of the expected implementation.
- Claude responds with "I can't" or evasions: Context may be missing (Python version, project structure) or the problem may be ambiguous. Simplify the test.
- Contradictory implementations between iterations: Claude "forgets" the previous ones. Include a summary in each iteration: "The current function does X. The problem is Y. I need it to do Z."
A Real Example: Building a Token Generator
Let's build generate_token step by step, simulating the cycle with Claude Code.
Step 1: The first test
# tests/test_tokens.py
import pytest
from auth.tokens import generate_token
def test_generate_token_returns_string():
"""The token must be a non-empty string."""
token = generate_token("user-123")
assert isinstance(token, str)
assert len(token) >= 32
A prompt to Claude Code:
Implement generate_token(user_id: str) -> str in auth/tokens.py.
It must pass the test in tests/test_tokens.py. Stdlib only.
Step 2: Claude implements (a possible response)
# auth/tokens.py
import uuid
import hashlib
def generate_token(user_id: str) -> str:
"""Generate a unique token for the given user."""
data = f"{user_id}-{uuid.uuid4()}"
return hashlib.sha256(data.encode()).hexdigest()
You run pytest tests/test_tokens.py -v → It passes. You review the code: it uses the stdlib, generates 64 characters (sha256 hex). Correct and appropriate.
Step 3: The second test
def test_token_contains_user_id():
"""The token must contain the user_id for later validation."""
token = generate_token("user-456")
assert "user-456" in token
You run the tests → It fails. The SHA256 hash doesn't include the user_id in plain text. The token is a3f2b1c... and doesn't contain "user-456".
Step 4: Iteration
You have two options:
A) The test is wrong: If the real design is for the token to be opaque (just the hash) and the user_id to be stored elsewhere (DB, session), then the test over-specifies. You should remove it or change it to something like test_token_different_for_different_users.
B) The test is right: You want the token to be "self-contained" and carry the user_id in a readable way (for example, user-456:signature). Then the problem is the implementation.
Let's assume you chose B. Your iteration prompt:
The test_token_contains_user_id test fails. The token must contain the user_id
in a readable format (e.g. "user-456:signature") so the backend can
extract it without querying the DB. Fix the implementation.
Claude Code might respond:
# auth/tokens.py
import uuid
import hashlib
import base64
def generate_token(user_id: str) -> str:
"""Generate a token containing user_id and a signature."""
if not user_id or not user_id.strip():
raise ValueError("user_id cannot be empty")
nonce = uuid.uuid4().hex
payload = f"{user_id}:{nonce}"
signature = hashlib.sha256(payload.encode()).hexdigest()[:32]
token = f"{user_id}:{signature}"
return base64.b64encode(token.encode()).decode()
You run the tests. Does test_token_contains_user_id pass? It depends: if the token is dXNlci00NTY6YWJjZGVm... (base64), "user-456" isn't in plain text in the string. You'd need to decode or adjust the test.
To simplify, an alternative design:
# auth/tokens.py
import uuid
import hashlib
def generate_token(user_id: str) -> str:
"""Generate a token with user_id prefix for validation."""
if not user_id or not user_id.strip():
raise ValueError("user_id cannot be empty")
nonce = uuid.uuid4().hex
signature = hashlib.sha256(f"{user_id}:{nonce}".encode()).hexdigest()[:24]
return f"{user_id}:{nonce}:{signature}"
Here the token is user-456:abc123def:xyz789... and "user-456" is present. It passes both tests.
Step 5: A test for ValueError
def test_generate_token_empty_user_raises():
"""An empty user_id must raise ValueError."""
with pytest.raises(ValueError, match="cannot be empty"):
generate_token("")
def test_generate_token_none_raises():
"""None must raise ValueError."""
with pytest.raises(ValueError):
generate_token(None) # type: ignore
If Claude hadn't handled these cases, they fail. The iteration prompt: "Add validation: an empty or None user_id must raise ValueError."
The evolution of the code across the cycles is incremental: each test forces a new behavior without breaking the previous ones.
A summary of the example
| Cycle | Test | Claude's result | Action |
|---|---|---|---|
| 1 | test_generate_token_returns_string | OK (sha256 hex) | Green, next |
| 2 | test_token_contains_user_id | Fails (opaque hash) | Iterate: add user_id to the format |
| 3 | test_generate_token_empty_user_raises | Fails (no validation) | Iterate: validate and raise ValueError |
| 4 | test_generate_token_none_raises | May fail | Iterate or add a type check |
Each iteration is a mini TDD cycle: test → implementation → validation → fix if needed.
Heuristics for When to Intervene
| Situation | Action |
|---|---|
| Claude fails the same test twice with the same error | Change your prompt. Add context, constraints, or an example of the expected output. |
| Claude passes the test but the code is bad | Add more specific tests. For example, @pytest.mark.parametrize with several inputs to prevent hardcoding. |
| Claude over-engineers | Add a constraint: "The minimal implementation that passes the tests. Don't add features that weren't asked for." |
| Claude hardcodes to pass | Use @pytest.mark.parametrize with multiple inputs. A single case allows cheating; several make it obvious. |
| Claude iterates but doesn't converge | Reduce the scope. If the test is too big, split it into smaller tests. |
| Doubts about whether the test is correct | Ask yourself: "Does this test reflect what I really want?" If not, adjust the test first. |
Practice: A Complete Cycle
Copy this skeleton and complete the cycle with Claude Code (or simulate the process by hand):
# tests/test_slugify.py
import pytest
from utils.text import slugify
def test_slugify_lowercases():
assert slugify("Hello World") == "hello-world"
def test_slugify_replaces_spaces_with_hyphens():
assert slugify("a b c") == "a-b-c"
def test_slugify_removes_special_chars():
assert slugify("hello!!!world") == "helloworld"
def test_slugify_empty_returns_empty():
assert slugify("") == ""
Create an empty utils/text.py (or with def slugify(s: str) -> str: raise NotImplementedError). Write the first test, ask Claude Code to implement it, and keep iterating with the decision framework when something fails.
A reference implementation for slugify
If you prefer to implement it by hand to practice TDD thinking, here's a minimal solution that passes the tests:
# utils/text.py
import re
import unicodedata
def slugify(s: str) -> str:
"""Convert string to URL-friendly slug."""
if not s:
return ""
# Normalize accents (é -> e)
s = unicodedata.normalize("NFD", s)
s = "".join(c for c in s if unicodedata.category(c) != "Mn")
# Lowercase, replace spaces and non-alphanumeric characters
s = s.lower().strip()
s = re.sub(r"[^a-z0-9\s-]", "", s)
s = re.sub(r"[-\s]+", "-", s)
return s.strip("-")
This implementation passes the example's four tests. Use it as a reference to compare with what Claude Code generates.
Troubleshooting
Problem 1: Claude Code ignores my constraints
Cause: The constraints are buried in long text or aren't very visible.
Solution: Put them at the end of the prompt, in a numbered list or with "Constraints:" as a heading. Repeat the most critical ones. If Claude keeps ignoring them, write: "IMPORTANT: Don't use [X]. Only [Y]."
Problem 2: The implementation passes the tests but breaks other code
Cause: Claude Code didn't see the code that calls this function. The signature or the contract don't match.
Solution: Include in the prompt the files that use the function. Example: "This function is called from auth/login.py, which does token = generate_token(user.id) and expects a str."
Problem 3: Claude Code iterates but doesn't fix the error
Cause: The error message isn't clear enough, or the test is too complex.
Solution: Copy pytest's exact output into the prompt. If the test verifies many things, split it into smaller tests and ask it to implement them one by one.
Problem 4: I don't know if the problem is the test or the implementation
Cause: The test is ambiguous or expresses the intent poorly.
Solution: Use the guiding question: "Does this test correctly specify what I want?" If you're not sure, write a simpler test that does express the rule. Then refine.
Problem 5: Claude generates 200 lines for a simple test
Cause: Without minimality constraints, Claude Code tends to be exhaustive.
Solution: Add "A minimal implementation. Only the code needed to pass the tests. No extra classes or unused helpers."
Project Connection
Every cycle of the authentication project (registration, login, tokens, validation) follows this flow:
- You write a red test (for example,
test_register_hashes_password) - You give Claude Code the test, constraints and interfaces
- Claude implements
- You run the tests
- If they fail, you apply the decision framework (is the test correct? the implementation?)
- You iterate until green
- You refactor if needed
- You move on to the next test
The project isn't just the final code: it's practicing this cycle many times until the judgment "do I step in or let it iterate?" becomes automatic.
Exercises
Exercise 1: Identify the type of error (Easy)
Claude Code implements a function and the test fails with:
AssertionError: assert 'user-123' in 'a1b2c3d4e5f6...'
According to the decision framework, could this be a test problem or an implementation problem? What would you do first?
See solution
It could be either:
- An implementation problem: If the test requires the user_id to be in the token (by design) and Claude generated an opaque hash, the implementation is what's failing. Action: give more context about the expected format.
- A test problem: If the real design is an opaque token + the user_id in the DB, the test is over-specifying. Action: change the test so it doesn't require the user_id to be in the token string.
First step: Read the test and ask "Does this assert reflect the design I want?" If yes, it's an implementation problem. If not, it's a spec problem.
Exercise 2: Write a good prompt (Medium)
You have this test:
def test_parse_config_returns_dict():
content = "key=value\nother=123"
result = parse_config(content)
assert result == {"key": "value", "other": "123"}
Write a complete prompt for Claude Code that includes a specification, constraints and interfaces. Assume parse_config will live in config/loader.py and will be used by main.py to load variables.
See solution
Implement parse_config(content: str) -> dict[str, str] in config/loader.py.
The test to satisfy (tests/test_config.py):
def test_parse_config_returns_dict():
content = "key=value\nother=123"
result = parse_config(content)
assert result == {"key": "value", "other": "123"}
Context: main.py does:
config = parse_config(Path("config.env").read_text())
db_url = config["database_url"]
Constraints:
- Stdlib only
- Empty lines must be ignored
- Lines without "=" must be ignored or raise ValueError (choose one and document it)
- Keys with no value (a "key=" line) must map to ""
Exercise 3: Apply the decision framework (Medium)
Claude implements slugify. The test_slugify_removes_accents test fails:
def test_slugify_removes_accents():
assert slugify("café") == "cafe"
Claude returns slugify("café") == "caf" (it truncates at the accent). Is this a test problem or an implementation problem? What action would you take?
See solution
Diagnosis: The test is correct. We want accents to be normalized (é → e), not removed by truncating.
Problem: The implementation. Claude probably filtered non-ASCII characters naively instead of using unicodedata.normalize.
Action: Give more context: "Use unicodedata to normalize accents (NFC/NFD) before slugifying. 'café' must result in 'cafe'."
Or let it iterate with the error: "The test expects 'café' to become 'cafe', but your implementation returns 'caf'. Fix it to normalize accented characters."
Exercise 4: Detect over-engineering (Medium)
Claude Code generates this for a test that only asks to "return the first element of a list":
class ListUtils:
@staticmethod
def first(lst: list) -> any:
if not isinstance(lst, list):
raise TypeError("Expected list")
if len(lst) == 0:
raise ValueError("List cannot be empty")
return lst[0]
The test was: assert first([1,2,3]) == 1. What constraint would you add to the next prompt to prevent this over-engineering?
See solution
Useful constraints:
- "A minimal implementation. A function, not a class. Only the code needed to pass the tests."
- "Don't add validations the tests don't ask for. If the test doesn't validate empty lists, don't add that check yet."
- "A 2-3 line function maximum for this case."
The principle: the tests define what to validate. If there's no test for "an empty list", don't add that handling until you write the test.
Exercise 5: Prevent hardcoding with parametrize (Hard)
You have a test that Claude "passes" by hardcoding:
def test_calculate_discount():
assert calculate_discount(100, 10) == 90
Claude implements: def calculate_discount(price, pct): return 90. Rewrite the test using @pytest.mark.parametrize so an obvious hardcode fails.
See solution
import pytest
@pytest.mark.parametrize("price, pct, expected", [
(100, 10, 90),
(200, 25, 150),
(50, 50, 25),
(100, 0, 100),
(100, 100, 0),
])
def test_calculate_discount(price, pct, expected):
assert calculate_discount(price, pct) == expected
With multiple cases, return 90 fails on the second and third case. Parametrize forces the real logic to be implemented.
Exercise 6: A complete iterative flow (Hard)
Simulate 3 TDD cycles for a validate_email(email: str) -> bool function:
- Cycle 1: a test that passes (a valid email returns True)
- Cycle 2: a test that fails initially (an invalid email returns False) — Claude iterates
- Cycle 3: a test that fails (an email with spaces returns False) — Claude iterates
Write the three tests, the cycle 1 prompt, and the iteration prompts for cycles 2 and 3.
See solution
Tests:
def test_valid_email_returns_true():
assert validate_email("user@example.com") is True
def test_invalid_email_returns_false():
assert validate_email("notanemail") is False
def test_email_with_spaces_returns_false():
assert validate_email("user @example.com") is False
Cycle 1 prompt:
Implement validate_email(email: str) -> bool in utils/validation.py.
It must pass: assert validate_email("user@example.com") is True
Stdlib only. A minimal implementation.
Cycle 2 iteration prompt: (assuming Claude was returning True for everything)
The test_invalid_email_returns_false test fails. validate_email("notanemail")
must return False because it doesn't have an @. Fix the implementation.
Cycle 3 iteration prompt:
The test_email_with_spaces_returns_false test fails. An email with spaces
("user @example.com") isn't valid and must return False. Adjust the validation.
Summary
- ✅ The implementation prompt must include: tests, constraints and interfaces.
- ✅ The pattern: "Implement [X] so it passes these tests: [tests]. Constraints: [list]."
- ✅ Evaluate not just whether the tests pass, but whether the code is correct, doesn't cheat and doesn't over-engineer.
- ✅ The decision framework: if the test is correct → an implementation problem (context, approach or iteration); if the test isn't correct → a spec problem (clarify, simplify or add a constraint).
- ✅ Give effective context: the test + existing code + explicit constraints.
- ✅ The loop: test → prompt → fails? → evaluate → re-prompt or adjust the test → repeat until green.
- ✅ Heuristics: the same error twice → change the prompt; bad code → more tests; over-engineering → a minimality constraint; hardcoding → parametrize.
- ✅ The implementation cycle with Claude Code becomes fluid with practice: writing clear specs, giving rich context and knowing when to step in are the three legs of the skill.
Next capsule: Refactoring with tests as a safety net — improving the code without breaking functionality.
Additional Resources
- Anthropic: Claude Code Best Practices - How to work effectively with Claude Code
- Tweag: Spec-Driven Development with LLMs - Spec-first and development with LLMs
- Martin Fowler: Test-Driven Development - The fundamentals of the red-green-refactor cycle
- pytest: Parametrize - Parametrized tests to prevent hardcoding
- Kent Beck: TDD by Example - The foundational TDD book
- Google: Testing Blog - Test-Driven Development - Articles on TDD and testing practices
Module 4, Capsule 04 — Testing with Claude Code Guide
The green cycle: effective prompts, critical evaluation and the judgment to step in