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:

  1. Escribe el test que describe el comportamiento esperado:
    def test_complete_task_requires_assignment():
        # Solo el usuario asignado puede marcar como completed
        ...
  2. Ejecuta pytest → rojo
  3. Prompt a Claude: "Implementa la regla: solo el assigned_to puede completar una tarea"
  4. 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

FeatureCiclos UnitCiclos IntegrationTotal
Auth325
Teams314
Tasks325

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


Módulo 8, Cápsula 03 — Testing with Claude Code Guide