Module 1: Spec-First Methodology and TDD with AI
Why TDD Matters More with AI Than Without It
Why TDD Matters More with AI Than Without It
Capsule overview
Claude Code can generate 200 lines of code in 30 seconds. How long would it take you to review those 200 lines to verify they're correct? And what if it generates 2,000 lines in a single session? The speed of generating code with AI is a superpower — but without automatic validation, it's a dangerous superpower.
This capsule explores the central problem that motivates the whole guide: AI's speed amplifies both correct code and bugs. More code per minute means more potential bugs per minute. The solution isn't "generate less code" (that would be absurd) — it's having a validation system that scales with the speed of generation. That system is tests.
By the end of this capsule, you'll understand why TDD isn't a nice-to-have when you work with AI — it's an operational necessity. And you'll have concrete arguments to convince your team to adopt it.
The Problem: Speed Without Validation
Before AI: The human pace had natural limits
When you write code by hand, your production speed is limited:
Typical manual development:
- 50-100 productive lines of code per hour
- Mental review as you write
- You compile/run frequently
- Bugs are caught "in the moment"
This pace has a hidden advantage: the limited speed acts as a quality buffer. As you write line by line, your brain reviews in real time. It's not perfect, but it's a natural filter.
With AI: Speed breaks the buffer
Claude Code removes the speed buffer:
Typical development with Claude Code:
- 200-500 lines of code in minutes
- Instant generation (you don't write, you receive)
- There's no "review as you write" — the code arrives complete
- Bugs hide in code that "looks correct"
The problem isn't that Claude Code generates bad code. AI-generated code is generally good for the happy path. The problem is that it generates code at a speed that outpaces your capacity for manual review.
The math of the risk
Imagine you review code with 95% accuracy (you catch 19 out of every 20 bugs):
Manual development:
- 100 lines/hour × 2% bug rate = 2 bugs/hour
- 95% detection rate = 1.9 bugs detected
- 0.1 bugs escape per hour ← Manageable
Development with AI:
- 500 lines/hour × 2% bug rate = 10 bugs/hour
- 95% detection rate = 9.5 bugs detected
- 0.5 bugs escape per hour ← 5x more bugs in production
Same bug rate, same review accuracy, but 5x more bugs in production. Speed amplifies the residual error.
The "Looks Good" Illusion
Why visual review fails with AI-generated code
When Claude Code generates code, you tend to do a visual review:
# Claude Code generates this for "implement a discount system":
def calculate_discount(price: float, discount_percent: float) -> float:
"""Calculate discounted price."""
if discount_percent < 0 or discount_percent > 100:
raise ValueError("Discount must be between 0 and 100")
discount_amount = price * (discount_percent / 100)
return price - discount_amount
Your visual review: "Looks good. It validates the range, calculates the discount, returns the price. Approved."
But what about these cases?
# What about a negative price?
calculate_discount(-50, 10) # Returns -45.0 ← Is that correct?
# What about price = 0?
calculate_discount(0, 50) # Returns 0.0 ← OK, but should it accept a price of 0?
# What about very large floats?
calculate_discount(1e308, 50) # Overflow?
# What about discount_percent = 100?
calculate_discount(100, 100) # Returns 0.0 ← Is a free product valid?
# What about incorrect types?
calculate_discount("fifty", 10) # Unhandled TypeError
The visual review caught that the happy path works. Tests would have caught that 5 edge cases aren't handled.
Confirmation bias with AI code
There's a cognitive bias that's especially dangerous with AI-generated code: you tend to look for reasons the code is correct instead of looking for reasons it might fail.
When you write code yourself, you're intimately aware of the decisions you made and the ones you avoided. When you receive code from Claude Code, you see the result but not the process — and the code "looks professional" because the AI generates well-formatted and well-named code.
Reviewing your own code:
- "I didn't handle the None case here... I should add that"
- "This validation doesn't cover empty strings"
- You know the weaknesses because you created them
Reviewing AI code:
- "Looks clean"
- "The variable names are good"
- "It has a docstring"
- You assume that "if it looks professional, it's correct"
What Happens Without Tests: The "Generate and Pray" Anti-Pattern
The workflow that does NOT work
This is the most common workflow among developers who use AI without testing:
1. You ask Claude Code: "Implement JWT authentication"
2. Claude Code generates ~150 lines of code
3. Visual review: "Looks good, it has login, register, tokens"
4. You copy it into the project
5. You test manually with curl/Postman
6. "It works with my test case"
7. Push to main
8. 3 days later: bug in production — tokens don't expire correctly
The problem isn't in step 2 (the generation). It's in steps 3-6 (the validation).
The counter-example: What happens WITH tests
1. You write tests BEFORE asking for the implementation:
- test_register_creates_user
- test_register_duplicate_email_fails
- test_login_returns_valid_jwt
- test_jwt_expires_after_timeout
- test_expired_jwt_is_rejected
- test_invalid_jwt_returns_401
2. You give the tests to Claude Code as context:
"Implement authentication so these tests pass"
3. Claude Code generates the implementation
4. You run pytest → 4/6 tests pass, 2 fail
5. Claude Code iterates on the failures
6. pytest → 6/6 tests pass
7. Push to main with confidence
The difference: In the second workflow, the test test_jwt_expires_after_timeout would have forced Claude Code to implement expiration correctly — because the test would have failed if it didn't.
Three Concrete Reasons Why TDD + AI > AI Alone
Reason 1: Tests eliminate ambiguity
When you tell Claude Code "implement JWT authentication," there are dozens of implicit decisions:
- How long does the token last? 1 hour? 24 hours? 1 week?
- What claims does the JWT include? Only user_id? Also role?
- How does it handle expired tokens? 401? 403?
- Does it accept refresh tokens?
- What happens with malformed tokens?
Without tests: Claude Code makes these decisions for you. Sometimes it gets them right, sometimes it doesn't.
With tests: Your tests define the answers:
def test_jwt_expires_in_one_hour():
token = create_token(user_id=1)
payload = decode_token(token)
assert payload["exp"] - payload["iat"] == 3600
def test_jwt_includes_user_id_and_role():
token = create_token(user_id=1, role="admin")
payload = decode_token(token)
assert payload["user_id"] == 1
assert payload["role"] == "admin"
def test_expired_token_raises_error():
expired_token = create_token(user_id=1, expires_delta=-1)
with pytest.raises(TokenExpiredError):
decode_token(expired_token)
Tests are unambiguous specification. Claude Code doesn't have to guess — your tests tell it exactly what should happen.
Reason 2: Tests scale with AI's speed
Manual review doesn't scale:
Claude Code generates 500 lines → Manual review: 30-60 minutes
Claude Code generates 2000 lines → Manual review: 2-4 hours (will you really do it?)
Claude Code generates 5000 lines → Manual review: You give up and "trust it"
Tests DO scale:
500 lines with 20 tests → pytest: 2 seconds
2000 lines with 80 tests → pytest: 5 seconds
5000 lines with 200 tests → pytest: 15 seconds
pytest doesn't get tired, doesn't have confirmation bias, and doesn't say "looks good" when there's a bug.
Reason 3: Tests enable automatic iteration loops
This is the most powerful differentiator of TDD with AI. Without tests, when Claude Code generates incorrect code, the cycle is:
Without tests:
1. Claude generates → 2. You review → 3. "This is wrong" →
4. You explain the problem in natural language →
5. Claude regenerates → 6. You review again → ...
With tests, the cycle is:
With tests:
1. Claude generates → 2. pytest → 3. "2 tests failed" →
4. Claude reads the pytest output →
5. Claude fixes automatically → 6. pytest → "All passed" ✅
The difference: With tests, Claude Code receives precise feedback and can self-correct. Without tests, you depend on your ability to explain the error — and your explanation might be ambiguous.
Comparison: Test-After vs Test-First with AI
| Criterion | Test-After (traditional) | Test-First (spec-first) |
|---|---|---|
| When you write tests | After implementing | Before implementing |
| The test's role | Verification | Specification |
| Ambiguity | Claude guesses requirements | Tests define requirements |
| Feedback | Manual (visual review) | Automatic (pytest) |
| Edge cases | You discover them later | You define them beforehand |
| Iteration loops | Manual (you explain) | Automatic (pytest output) |
| Confidence | "Looks good" | "Tests pass" |
When to use test-after? When you have legacy code without tests and need to add coverage retroactively (covered in Module 5).
When to use test-first? Whenever you're building something new with Claude Code. It's the default of this guide.
Trade-off: Test-first requires more up-front effort (defining tests before seeing code). But the ROI is massive: fewer bugs, less manual review, automatic iteration loops.
Project Connection
In the Spec-first mini-app (this module's project):
- You'll see in action how ambiguity disappears when you define tests first (Reason 1)
- You'll experience how pytest validates in seconds what would take minutes to review manually (Reason 2)
- You'll run your first automatic iteration loop with Claude Code (Reason 3)
Everything you learn today applies in every project of the guide — and in every future project with Claude Code.
Troubleshooting
Problem 1: "But I DO review Claude Code's code well"
Cause: Overestimating manual review capacity. Studies show that even senior developers catch ~60-85% of bugs in code review, not 100%.
Solution: It's not about replacing your review — it's about complementing it. Tests + visual review > visual review alone. Tests catch what your review doesn't see (especially edge cases and regressions).
Problem 2: "Writing tests first takes more time"
Cause: A short-term perspective. Writing tests first takes 5-10 minutes more per feature.
Solution: Measure the total time including later debugging. Without tests, you spend 30-60 minutes debugging bugs you would have caught with 5 minutes of tests. The ROI is 3-6x.
Problem 3: "I don't know what to test yet — I need to see the code first"
Cause: Confusing "I don't know how to implement it" with "I don't know what should happen." You don't need to know the how to define the what.
Solution: Think about behavior, not implementation. You don't need to know how calculate_discount is implemented to know that calculate_discount(100, 10) should return 90. Define WHAT should happen — Claude Code takes care of the HOW.
Problem 4: "Claude Code already generates tests if I ask it to"
Cause: Confusion between test-generation and spec-first. Claude Code can generate tests, yes — but those tests verify the implementation it ALREADY generated. They're test-after, not test-first. They validate that the code does what it does, not that it does what YOU want.
Solution: The tests YOU define reflect your intent. The tests Claude Code generates reflect the implementation. Both are useful, but only yours are spec-first.
Exercises
Exercise 1: Identify the risk (Easy)
Claude Code generates this function. Do a visual review and list 3 edge cases that are NOT covered:
def calculate_bmi(weight_kg: float, height_m: float) -> str:
bmi = weight_kg / (height_m ** 2)
if bmi < 18.5:
return "underweight"
elif bmi < 25:
return "normal"
elif bmi < 30:
return "overweight"
else:
return "obese"
See solution
Uncovered edge cases:
- height_m = 0: Division by zero (
ZeroDivisionError) - negative weight_kg: A negative BMI makes no medical sense
- negative height_m: Height squared is positive, but the input is invalid
- Incorrect types:
calculate_bmi("heavy", 1.75)→TypeError - Extreme values:
calculate_bmi(1, 0.01)→ BMI = 10000 (not realistic)
# Tests that would have forced handling these cases:
def test_bmi_zero_height_raises_error():
with pytest.raises(ValueError, match="height must be positive"):
calculate_bmi(70, 0)
def test_bmi_negative_weight_raises_error():
with pytest.raises(ValueError, match="weight must be positive"):
calculate_bmi(-70, 1.75)
def test_bmi_negative_height_raises_error():
with pytest.raises(ValueError, match="height must be positive"):
calculate_bmi(70, -1.75)
Explanation: The visual review focused on the BMI logic (which is correct). The tests would have forced input validation.
Exercise 2: Write the test first (Easy)
You're going to ask Claude Code to implement a function is_valid_email(email: str) -> bool. BEFORE asking for the implementation, write 5 tests that define the expected behavior.
See solution
import pytest
def test_valid_email_returns_true():
assert is_valid_email("user@example.com") == True
def test_email_without_at_returns_false():
assert is_valid_email("userexample.com") == False
def test_email_without_domain_returns_false():
assert is_valid_email("user@") == False
def test_empty_string_returns_false():
assert is_valid_email("") == False
def test_email_with_spaces_returns_false():
assert is_valid_email("user @example.com") == False
Explanation: These 5 tests define the contract of is_valid_email without knowing how it's implemented. You don't need to know whether it uses regex, manual parsing, or a library — you just define what should happen with each input. When you give these tests to Claude Code, the implementation is forced to handle these cases.
Exercise 3: Compare workflows (Medium)
Imagine you need a function parse_csv_line(line: str) -> list[str] that parses a CSV line. Describe the two workflows (with tests and without tests) and explain what could go wrong in each.
See solution
Workflow without tests:
1. "Claude, implement parse_csv_line that parses a CSV line"
2. Claude generates a function with split(",")
3. Visual review: "Looks good"
4. In production: it fails with "John, Jr.,30,New York" (comma inside the name)
Workflow with tests:
def test_simple_csv():
assert parse_csv_line("a,b,c") == ["a", "b", "c"]
def test_csv_with_quotes():
assert parse_csv_line('"John, Jr.",30,NYC') == ["John, Jr.", "30", "NYC"]
def test_empty_fields():
assert parse_csv_line("a,,c") == ["a", "", "c"]
def test_empty_line():
assert parse_csv_line("") == []
def test_single_field():
assert parse_csv_line("hello") == ["hello"]
1. You give the tests to Claude Code
2. Claude generates an implementation that handles quotes, empty fields, etc.
3. pytest → All pass
4. Real confidence based on tests, not on "looks good"
Explanation: Without tests, Claude Code would probably use line.split(",") which fails with quoted fields. With tests, the test_csv_with_quotes test would have forced Claude Code to use csv.reader or an implementation that handles quotes correctly.
Exercise 4: Calculate the ROI of testing (Medium)
You have a project where Claude Code generates ~1000 lines of code per session. Assuming a 3% bug rate and a fix cost of 30 minutes per bug in production vs 5 minutes if it's caught with tests:
- How many potential bugs per session?
- If your manual review catches 80%, how many escape?
- How much time do you lose on production fixes?
- If writing tests takes 20 minutes per session, what's the ROI?
See solution
1. Potential bugs: 1000 × 3% = 30 bugs/session
2. Bugs that escape review: 30 × 20% = 6 bugs reach production
3. Fix time in production: 6 × 30 min = 180 min = 3 hours
4. With tests (assuming 95% detection):
- Tests catch: 30 × 95% = 28.5 bugs
- Fix with test feedback: 28.5 × 5 min = 142.5 min
- Bugs that escape: 30 × 5% = 1.5 bugs
- Fix in production: 1.5 × 30 min = 45 min
Total time WITHOUT tests: 180 min of fixes in production
Total time WITH tests: 20 min (writing) + 142.5 min (fast fix) + 45 min (production) = 207.5 min
But the "fast fix" time is absorbed during development
(Claude Code iterates automatically with test feedback)
NET additional time: 20 min of writing tests
Time SAVED: 180 - 45 = 135 min of debugging in production
ROI: 135 min saved / 20 min invested = 6.75x
Explanation: The real ROI is even higher because it doesn't account for the reputational cost of bugs in production, the debugging time in context (reproduce, diagnose, fix, deploy), or the value of confidence for future refactoring.
Exercise 5: Identify test-after vs test-first (Hard)
Read these two scenarios and determine which one is test-after and which is test-first. Explain why one produces more reliable results.
Scenario A:
1. "Claude, implement a rate limiter that allows max 100 requests per minute"
2. Claude generates the implementation
3. "Claude, now write tests for the rate limiter you generated"
4. Claude generates tests that pass with the implementation
Scenario B:
1. You write: test_allows_100_requests_per_minute
2. You write: test_blocks_101st_request
3. You write: test_resets_after_one_minute
4. You write: test_tracks_per_client_ip
5. "Claude, implement a rate limiter that passes these tests"
6. Claude generates the implementation
See solution
Scenario A: Test-after. Claude Code generates tests that verify what the implementation DOES, not what it SHOULD do. If the implementation has a bug (for example, it doesn't reset the counter after a minute), the generated tests won't test for that — because they're based on the implementation, not on the requirements.
Scenario B: Test-first (spec-first). The tests define the expected behavior BEFORE any implementation exists. test_resets_after_one_minute and test_tracks_per_client_ip are requirements that might not have been implemented if they hadn't been specified as tests.
Why B produces more reliable results:
- B's tests reflect the developer's intent (what they WANT)
- A's tests reflect the current behavior (what the AI GENERATED)
- If A's implementation has a bug, A's tests probably won't catch it
- If B's implementation has a bug, B's tests catch it immediately
Rule: AI-generated tests over AI-generated code = circular validation (unreliable). Human-written tests over AI-generated code = real validation.
Summary
In this capsule you learned:
- ✅ AI's speed amplifies both good code and bugs — manual review doesn't scale with that speed
- ✅ Visual review suffers from confirmation bias: AI code "looks professional" but may have unhandled edge cases
- ✅ The "generate and pray" workflow is the most common and most costly anti-pattern
- ✅ TDD with AI eliminates ambiguity (tests define requirements), scales with speed (pytest validates in seconds), and enables automatic iteration loops
- ✅ Test-first (spec-first) produces more reliable results than test-after because the tests reflect your intent, not the AI's implementation
- ✅ The ROI of testing with AI is significant: minutes invested save hours of debugging
Next capsule: Spec-First Methodology — the Tweag methodology and the inversion of control with Claude Code.
Additional Resources
- Tweag: Spec-Driven Development with LLMs - The origin of the spec-first methodology applied to LLM development
- pytest: Getting Started - Basic pytest setup (you'll use it from module 2 on)
- Anthropic: Best Practices for Claude Code - Official best practices for working with Claude Code
- Martin Fowler: Is TDD Dead? - The classic debate about TDD — useful context
- Greg Wilson: Software Engineering's Greatest Hits - Empirical evidence on development practices that work
- Code Review Research by Microsoft - Research on the limitations of manual code review
Module 1, Capsule 02 — Testing with Claude Code Guide Speed without validation is accelerated technical debt