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:

  1. Write the test that describes the expected behavior:
    def test_complete_task_requires_assignment():
        # Only the assigned user can mark it as completed
        ...
  2. Run pytest → red
  3. A prompt to Claude: "Implement the rule: only the assigned_to can complete a task"
  4. 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

FeatureUnit cyclesIntegration cyclesTotal
Auth325
Teams314
Tasks325

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


Module 8, Capsule 03 — Testing with Claude Code Guide