Module 8: Capstone Project — A Complete Test Suite with TDD
Core Features with TDD
Core Features with TDD
Capsule overview
You already have the spec-first plan: the tests are defined, the file structure exists, and you know exactly which behaviors you need. Now comes the implementation phase: building each feature of the TaskFlow API using TDD cycles with Claude Code. Every test you wrote in the previous capsule becomes a red-green-refactor cycle where you define the contract and Claude Code implements.
This capsule covers the application's three core modules: Auth, Teams and Tasks. For each one you'll see complete TDD cycles with real prompts, test code, and strategies for handling spec refinement when you discover tests that were missing.
The TDD Rhythm with Claude Code
The structure of each cycle
1. RED: You write the test → pytest fails (as expected)
2. GREEN: A prompt to Claude Code → the implementation → pytest passes
3. REFACTOR: You review the code → improve readability → pytest still passes
The value of this rhythm with AI: you don't implement the "how". You write the "what" (the test). Claude Code translates the "what" into the "how". The tests validate that the translation was correct.
When to do spec refinement
If during the implementation you discover that a scenario the test didn't cover is missing, don't implement it "just in case". Write a new test first. That's spec refinement in real time: the spec expands when the reality of the domain reveals a gap to you.
Feature 1: Auth — TDD Cycles
Cycle 1: Basic registration
Tests (unit):
# tests/unit/test_auth.py
def test_register_creates_user_with_hashed_password():
from app.auth.service import AuthService
service = AuthService()
user = service.register("alice@example.com", "SecurePass123!")
assert user["email"] == "alice@example.com"
assert "id" in user
assert "password" not in user
assert user["password_hash"] != "SecurePass123!"
def test_register_duplicate_email_raises():
service = AuthService()
service.register("alice@example.com", "SecurePass123!")
with pytest.raises(ValueError, match="already registered"):
service.register("alice@example.com", "AnotherPass456!")
A prompt to Claude Code:
Implement AuthService in app/auth/service.py. Use in-memory storage (a dict).
- register(email, password) creates a user, hashes the password with hashlib, returns a dict with id, email, password_hash (never the plain password)
- If the email already exists, raise ValueError("already registered")
- Use the structure: { "users": {} } for the in-memory store
Evaluation: Do the tests pass? Is the hash really different from the password? Are duplicates validated?
Cycle 2: Login and tokens
Tests (unit):
def test_login_returns_token_for_valid_credentials():
service = AuthService()
service.register("alice@example.com", "SecurePass123!")
result = service.login("alice@example.com", "SecurePass123!")
assert "token" in result
assert isinstance(result["token"], str)
assert len(result["token"]) > 0
def test_login_wrong_password_raises():
service = AuthService()
service.register("alice@example.com", "SecurePass123!")
with pytest.raises(ValueError, match="Invalid credentials"):
service.login("alice@example.com", "WrongPassword")
def test_login_nonexistent_user_raises():
service = AuthService()
with pytest.raises(ValueError, match="Invalid credentials"):
service.login("nobody@example.com", "SomePass123!")
A prompt to Claude Code:
Extend AuthService with login(email, password):
- If the credentials are valid: return {"token": "<jwt-like string>"}
- Use secrets to generate the token, include user_id and email in the payload (json + base64)
- If the email doesn't exist or the password is incorrect: raise ValueError("Invalid credentials")
Spec refinement: If you discover you need a get_user_from_token method, write that test before asking Claude for it.
Cycle 3: Token validation
Tests (unit):
def test_validate_token_returns_user_for_valid_token():
service = AuthService()
service.register("alice@example.com", "SecurePass123!")
result = service.login("alice@example.com", "SecurePass123!")
user = service.validate_token(result["token"])
assert user["email"] == "alice@example.com"
assert "id" in user
def test_validate_token_invalid_raises():
service = AuthService()
with pytest.raises(ValueError, match="Invalid token"):
service.validate_token("fake-invalid-token")
def test_validate_token_tampered_raises():
service = AuthService()
service.register("alice@example.com", "SecurePass123!")
result = service.login("alice@example.com", "SecurePass123!")
tampered = result["token"][:-5] + "xxxxx"
with pytest.raises(ValueError, match="Invalid token"):
service.validate_token(tampered)
A prompt to Claude Code:
Add validate_token(token) to AuthService:
- Decode the token, extract user_id, look up the user in the store
- If the token is invalid or the user doesn't exist: raise ValueError("Invalid token")
Cycle 4: Auth endpoints (integration)
Tests (integration with TestClient):
# tests/integration/test_auth_endpoints.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_post_register_creates_user_returns_201():
response = client.post("/auth/register", json={
"email": "alice@example.com",
"password": "SecurePass123!"
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "alice@example.com"
assert "id" in data
assert "password" not in data
def test_post_register_duplicate_email_returns_400():
client.post("/auth/register", json={
"email": "bob@example.com",
"password": "SecurePass123!"
})
response = client.post("/auth/register", json={
"email": "bob@example.com",
"password": "OtherPass456!"
})
assert response.status_code == 400
def test_post_login_returns_token():
client.post("/auth/register", json={
"email": "charlie@example.com",
"password": "SecurePass123!"
})
response = client.post("/auth/login", json={
"email": "charlie@example.com",
"password": "SecurePass123!"
})
assert response.status_code == 200
assert "token" in response.json()
A prompt to Claude Code:
Create the endpoints in app/main.py:
- POST /auth/register {email, password} -> 201 with the user, 400 if duplicate
- POST /auth/login {email, password} -> 200 with {token}, 400 if invalid
Use AuthService from app.auth.service. Inject a shared instance of AuthService (or use a dependency).
Cycle 5: Refinement and input validation
Spec refinement: If you tested manually and found that an empty email or a very short password aren't validated, write:
def test_register_invalid_email_returns_400():
response = client.post("/auth/register", json={
"email": "not-an-email",
"password": "SecurePass123!"
})
assert response.status_code == 422 # Validation error
def test_register_weak_password_returns_400():
response = client.post("/auth/register", json={
"email": "alice@example.com",
"password": "short"
})
assert response.status_code == 400 or response.status_code == 422
A prompt to Claude Code:
Add validation with Pydantic for register:
- email must be a valid format
- password minimum 8 characters, at least 1 uppercase, 1 number
- Return 422 for validation errors
Feature 2: Teams — TDD Cycles
Cycle 1: Creating a team
Tests (unit):
# tests/unit/test_teams.py
def test_create_team_returns_team_with_owner():
from app.teams.service import TeamService
service = TeamService()
team = service.create_team("Developers", owner_id=1)
assert team["name"] == "Developers"
assert team["owner_id"] == 1
assert "id" in team
assert 1 in team["member_ids"]
def test_create_team_stores_in_memory():
service = TeamService()
t1 = service.create_team("Team A", owner_id=1)
t2 = service.create_team("Team B", owner_id=2)
assert t1["id"] != t2["id"]
A prompt to Claude Code:
Implement TeamService in app/teams/service.py.
- create_team(name, owner_id) creates a team with a unique id
- The owner is automatically a member
- In-memory storage
Cycle 2: Adding a member
Tests (unit):
def test_add_member_adds_user_to_team():
service = TeamService()
team = service.create_team("Devs", owner_id=1)
service.add_member(team["id"], user_id=2, added_by=1)
updated = service.get_team(team["id"])
assert 2 in updated["member_ids"]
def test_add_member_only_owner_can_add():
service = TeamService()
team = service.create_team("Devs", owner_id=1)
service.add_member(team["id"], user_id=2, added_by=1) # owner adds
with pytest.raises(PermissionError, match="Only owner"):
service.add_member(team["id"], user_id=3, added_by=2) # member 2 tries
A prompt to Claude Code:
Add add_member(team_id, user_id, added_by) to TeamService:
- Only the owner (added_by == owner_id) can add members
- If it isn't the owner: raise PermissionError("Only owner can add members")
- get_team(team_id) must exist to get a team by id
Cycle 3: Listing teams
Tests (unit):
def test_list_teams_returns_all_teams():
service = TeamService()
service.create_team("Team A", owner_id=1)
service.create_team("Team B", owner_id=2)
teams = service.list_teams()
assert len(teams) == 2
def test_list_teams_by_member_returns_only_member_teams():
service = TeamService()
t1 = service.create_team("Team A", owner_id=1)
service.add_member(t1["id"], user_id=2, added_by=1)
t2 = service.create_team("Team B", owner_id=3)
teams = service.list_teams(member_id=2)
assert len(teams) == 1
assert teams[0]["name"] == "Team A"
Cycle 4: Team endpoints (integration)
Tests (integration):
# tests/integration/test_team_endpoints.py
def test_post_team_creates_team_requires_auth():
# First log in to get a token
client.post("/auth/register", json={"email": "owner@test.com", "password": "SecurePass123!"})
login = client.post("/auth/login", json={"email": "owner@test.com", "password": "SecurePass123!"})
token = login.json()["token"]
response = client.post("/teams", json={"name": "Dev Team"},
headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 201
assert response.json()["name"] == "Dev Team"
def test_post_team_without_auth_returns_401():
response = client.post("/teams", json={"name": "Dev Team"})
assert response.status_code == 401
A prompt to Claude Code:
Create the endpoints:
- POST /teams {name} -> 201, requires a Bearer token
- GET /teams -> lists the authenticated user's teams
- POST /teams/{id}/members {user_id} -> 200, only the owner can add
Use a dependency to get the current user from the token.
Feature 3: Tasks CRUD — TDD Cycles
Cycle 1: Creating a task
Tests (unit):
# tests/unit/test_tasks.py
def test_create_task_returns_task():
from app.tasks.service import TaskService
service = TaskService()
task = service.create_task(
title="Implement auth",
team_id=1,
created_by=1,
status="pending"
)
assert task["title"] == "Implement auth"
assert task["status"] == "pending"
assert task["team_id"] == 1
assert "id" in task
Cycle 2: Getting, updating, deleting
Tests (unit):
def test_get_task_returns_task_by_id():
service = TaskService()
created = service.create_task("Task 1", team_id=1, created_by=1)
task = service.get_task(created["id"])
assert task["title"] == "Task 1"
def test_update_task_modifies_fields():
service = TaskService()
created = service.create_task("Task 1", team_id=1, created_by=1)
updated = service.update_task(created["id"], title="Task 1 Updated", status="in_progress")
assert updated["title"] == "Task 1 Updated"
assert updated["status"] == "in_progress"
def test_delete_task_removes_task():
service = TaskService()
created = service.create_task("Task 1", team_id=1, created_by=1)
service.delete_task(created["id"])
with pytest.raises(ValueError, match="Task not found"):
service.get_task(created["id"])
Cycle 3: Assigning and filtering
Tests (unit):
def test_assign_task_sets_assigned_to():
service = TaskService()
task = service.create_task("Task 1", team_id=1, created_by=1)
updated = service.assign_task(task["id"], user_id=2)
assert updated["assigned_to"] == 2
def test_list_tasks_filters_by_status():
service = TaskService()
service.create_task("T1", team_id=1, created_by=1, status="pending")
service.create_task("T2", team_id=1, created_by=1, status="completed")
tasks = service.list_tasks(team_id=1, status="pending")
assert len(tasks) == 1
assert tasks[0]["title"] == "T1"
Cycles 4 and 5: Task endpoints (integration)
Tests (integration):
# tests/integration/test_task_endpoints.py
def test_full_crud_flow_with_auth():
# Register + login
client.post("/auth/register", json={"email": "user@test.com", "password": "SecurePass123!"})
login = client.post("/auth/login", json={"email": "user@test.com", "password": "SecurePass123!"})
token = login.json()["token"]
# Create team
team_resp = client.post("/teams", json={"name": "Dev"}, headers={"Authorization": f"Bearer {token}"})
team_id = team_resp.json()["id"]
# Create task
task_resp = client.post("/teams/{}/tasks".format(team_id), json={
"title": "First task",
"status": "pending"
}, headers={"Authorization": f"Bearer {token}"})
assert task_resp.status_code == 201
task_id = task_resp.json()["id"]
# Get task
get_resp = client.get("/tasks/{}".format(task_id), headers={"Authorization": f"Bearer {token}"})
assert get_resp.status_code == 200
# Update task
update_resp = client.patch("/tasks/{}".format(task_id), json={"status": "in_progress"},
headers={"Authorization": f"Bearer {token}"})
assert update_resp.status_code == 200
Spec Refinement During the Implementation
The pattern: discovering a gap
While you implement, sometimes you run a mental or manual flow and think: "What happens if...?"
An example: "What happens if I try to complete a task that isn't assigned?"
Don't implement the validation directly. Instead:
- Write the test that describes the expected behavior:
def test_complete_task_requires_assignment(): # Only the assigned user can mark it as completed ... - Run pytest → red
- A prompt to Claude: "Implement the rule: only the assigned_to can complete a task"
- Evaluate → green
That's spec refinement: the spec (the tests) grows when you discover requirements you hadn't considered.
When to stop and refine
- When a test fails for a reason that isn't "the implementation is missing" but "a case is missing"
- When you test manually and find an edge case
- When Claude Code implements something that "works" but violates a business rule you hadn't written as a test
Tips for Keeping the TDD Rhythm
1. Short cycles
2-4 tests per cycle is the sweet spot. More than 5 and Claude Code can get lost; fewer than 2 and you advance very slowly.
2. One feature at a time
Don't mix auth + teams + tasks in a single prompt. Feature by feature, cycle by cycle.
3. Integration tests after unit
First make the logic work (unit). Then make the endpoints work (integration). The order matters because the integration tests depend on auth and the services being ready.
4. Document each cycle
Note down: "Cycle N: test_X → Claude passed/failed → [action]". It's useful for the retrospective and for your portfolio.
5. If Claude Code fails
Don't delete the test. Analyze: is the test wrong? Is the spec ambiguous? Give it more context in the prompt: "The test expects X. It currently returns Y. I need it to return X when Z."
A Summary of Cycles per Feature
| Feature | Unit cycles | Integration cycles | Total |
|---|---|---|---|
| Auth | 3 | 2 | 5 |
| Teams | 3 | 1 | 4 |
| Tasks | 3 | 2 | 5 |
By the end of this capsule you'll have the TaskFlow API's core working: register, login, teams, tasks CRUD. The next capsule adds business logic (permissions, status transitions, stats) and edge cases.
Resources
- FastAPI TestClient
- pytest fixtures
- Module 4 of this guide: The Complete TDD Workflow
Module 8, Capsule 03 — Testing with Claude Code Guide