Módulo 8: Proyecto Integrador — Test Suite Completa con TDD
Core Features con TDD
Core Features con TDD
Descripción de la cápsula
Ya tienes el plan spec-first: los tests están definidos, la estructura de archivos existe, y sabes exactamente qué comportamientos necesitas. Ahora viene la fase de implementación: construir cada feature del TaskFlow API usando ciclos TDD con Claude Code. Cada test que escribiste en la cápsula anterior se convierte en un ciclo red-green-refactor donde tú defines el contrato y Claude Code implementa.
Esta cápsula cubre los tres módulos core de la aplicación: Auth, Teams y Tasks. Para cada uno verás ciclos TDD completos con prompts reales, código de tests, y estrategias para manejar spec refinement cuando descubres tests que faltaban.
El Ritmo TDD con Claude Code
Estructura de cada ciclo
1. RED: Escribes el test → pytest falla (esperado)
2. GREEN: Prompt a Claude Code → implementación → pytest pasa
3. REFACTOR: Revisas código → mejoras legibilidad → pytest sigue pasando
El valor de este ritmo con AI: tú no implementas el "cómo". Tú escribes el "qué" (el test). Claude Code traduce el "qué" en "cómo". Los tests validan que la traducción fue correcta.
Cuándo hacer spec refinement
Si durante la implementación descubres que falta un escenario que el test no cubría, no lo implementes "por si acaso". Escribe un nuevo test primero. Eso es spec refinement en tiempo real: la spec se expande cuando la realidad del dominio te revela un gap.
Feature 1: Auth — Ciclos TDD
Ciclo 1: Registro básico
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!")
Prompt a Claude Code:
Implementa AuthService en app/auth/service.py. Usa almacenamiento in-memory (dict).
- register(email, password) crea usuario, hashea password con hashlib, retorna dict con id, email, password_hash (nunca el password plano)
- Si email ya existe, raise ValueError("already registered")
- Usa estructura: { "users": {} } para el store in-memory
Evaluación: ¿Los tests pasan? ¿El hash es realmente diferente al password? ¿Se valida duplicados?
Ciclo 2: Login y 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!")
Prompt a Claude Code:
Extiende AuthService con login(email, password):
- Si credentials válidos: retorna {"token": "<jwt-like string>"}
- Usa secrets para generar token, incluye user_id y email en payload (json + base64)
- Si email no existe o password incorrecto: raise ValueError("Invalid credentials")
Spec refinement: Si descubres que necesitas un método get_user_from_token, escribe ese test antes de pedirlo a Claude.
Ciclo 3: Validación de token
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)
Prompt a Claude Code:
Agrega validate_token(token) a AuthService:
- Decodifica token, extrae user_id, busca usuario en store
- Si token inválido o usuario no existe: raise ValueError("Invalid token")
Ciclo 4: Endpoints de Auth (integration)
Tests (integration con 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()
Prompt a Claude Code:
Crea los endpoints en app/main.py:
- POST /auth/register {email, password} -> 201 con user, 400 si duplicate
- POST /auth/login {email, password} -> 200 con {token}, 400 si invalid
Usa AuthService desde app.auth.service. Inyecta una instancia compartida de AuthService (o usa dependency).
Ciclo 5: Refinamiento y validación de input
Spec refinement: Si probaste manualmente y encontraste que email vacío o password muy corto no se validan, escribe:
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
Prompt a Claude Code:
Agrega validación con Pydantic para register:
- email debe ser formato válido
- password mínimo 8 caracteres, al menos 1 mayúscula, 1 número
- Retorna 422 para validation errors
Feature 2: Teams — Ciclos TDD
Ciclo 1: Crear equipo
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"]
Prompt a Claude Code:
Implementa TeamService en app/teams/service.py.
- create_team(name, owner_id) crea equipo con id único
- owner es miembro automáticamente
- Almacenamiento in-memory
Ciclo 2: Agregar miembro
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
Prompt a Claude Code:
Agrega add_member(team_id, user_id, added_by) a TeamService:
- Solo el owner (added_by == owner_id) puede agregar miembros
- Si no es owner: raise PermissionError("Only owner can add members")
- get_team(team_id) debe existir para obtener equipo por id
Ciclo 3: Listar equipos
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"
Ciclo 4: Endpoints de Teams (integration)
Tests (integration):
# tests/integration/test_team_endpoints.py
def test_post_team_creates_team_requires_auth():
# Primero login para obtener 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
Prompt a Claude Code:
Crea endpoints:
- POST /teams {name} -> 201, requiere Bearer token
- GET /teams -> lista equipos del usuario autenticado
- POST /teams/{id}/members {user_id} -> 200, solo owner puede agregar
Usa dependecy para obtener usuario actual desde token.
Feature 3: Tasks CRUD — Ciclos TDD
Ciclo 1: Crear tarea
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
Ciclo 2: Obtener, actualizar, eliminar
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"])
Ciclo 3: Asignar y filtrar
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"
Ciclo 4 y 5: Endpoints de Tasks (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 Durante la Implementación
Patrón: descubrir un gap
Mientras implementas, a veces ejecutas un flujo mental o manual y piensas: "¿Qué pasa si...?"
Ejemplo: "¿Qué pasa si intento completar una tarea que no está asignada?"
No implementes la validación directamente. En su lugar:
- Escribe el test que describe el comportamiento esperado:
def test_complete_task_requires_assignment(): # Solo el usuario asignado puede marcar como completed ... - Ejecuta pytest → rojo
- Prompt a Claude: "Implementa la regla: solo el assigned_to puede completar una tarea"
- Evalúa → verde
Eso es spec refinement: la spec (tests) crece cuando descubres requisitos que no habías considerado.
Cuándo parar y refinar
- Cuando un test falla por una razón que no es "falta implementación" sino "falta un caso"
- Cuando manualmente pruebas y encuentras un edge case
- Cuando Claude Code implementa algo que "funciona" pero viola una regla de negocio que no habías escrito como test
Tips para Mantener el Ritmo TDD
1. Ciclos cortos
2-4 tests por ciclo es el sweet spot. Más de 5 y Claude Code puede perderse; menos de 2 y avanzas muy lento.
2. Un feature a la vez
No mezcles auth + teams + tasks en un solo prompt. Feature por feature, ciclo por ciclo.
3. Integration tests después de unit
Primero haz que la lógica funcione (unit). Luego que los endpoints funcionen (integration). El orden importa porque los integration tests dependen de que auth y servicios estén listos.
4. Documenta cada ciclo
Anota: "Ciclo N: test_X → Claude pasó/falló → [acción]". Sirve para la retrospectiva y para tu portfolio.
5. Si Claude Code falla
No borres el test. Analiza: ¿el test está mal? ¿La spec es ambigua? Dale más contexto en el prompt: "El test espera X. Actualmente retorna Y. Necesito que retorne X cuando Z."
Resumen de Ciclos por Feature
| Feature | Ciclos Unit | Ciclos Integration | Total |
|---|---|---|---|
| Auth | 3 | 2 | 5 |
| Teams | 3 | 1 | 4 |
| Tasks | 3 | 2 | 5 |
Al terminar esta cápsula tendrás el core de TaskFlow API funcionando: register, login, equipos, tareas CRUD. La siguiente cápsula añade business logic (permisos, transiciones de estado, stats) y edge cases.
Recursos
- FastAPI TestClient
- pytest fixtures
- Módulo 4 de esta guía: TDD Workflow Completo
Módulo 8, Cápsula 03 — Testing with Claude Code Guide