Módulo 8: Proyecto Integrador — Test Suite Completa con TDD
Mocks, Fixtures y CI Pipeline
Mocks, Fixtures y CI Pipeline
Descripción de la cápsula
Con el core de TaskFlow API funcionando, ahora profesionalizas la test suite: jerarquía de conftest.py, mocks para servicios externos, tests E2E de flujos completos, y el pipeline de CI en GitHub Actions. También verificas que el coverage final cumpla el objetivo ≥90%.
Esta cápsula cierra la capa de testing y automatización. Al terminar, tendrás un proyecto con tests que corren en cada push, reportes de coverage descargables, y un README con badge de CI listo para portfolio.
Jerarquía Profesional de conftest.py
Por qué múltiples conftest
Los fixtures se heredan: conftest.py en la raíz de tests/ define fixtures disponibles para todos los tests; conftest.py en tests/unit/ o tests/integration/ define fixtures específicas de ese nivel. Esto evita duplicación y mantiene cada capa con sus propias necesidades.
Estructura objetivo
tests/
├── conftest.py # Fixtures globales: app, client, auth helpers
├── unit/
│ ├── conftest.py # Fixtures para unit: services aislados
│ ├── test_auth.py
│ ├── test_teams.py
│ └── ...
├── integration/
│ ├── conftest.py # Fixtures para integration: client + auth
│ ├── test_auth_endpoints.py
│ └── ...
└── e2e/
├── conftest.py # Fixtures para E2E: setup completo
└── test_flows.py
conftest.py Raíz (tests/conftest.py)
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
"""TestClient para toda la suite."""
return TestClient(app)
@pytest.fixture
def auth_service():
"""Instancia limpia de AuthService para tests que necesitan control total."""
from app.auth.service import AuthService
return AuthService()
@pytest.fixture
def team_service():
from app.teams.service import TeamService
return TeamService()
@pytest.fixture
def task_service():
from app.tasks.service import TaskService
return TaskService()
Si tu app usa almacenamiento global (dicts compartidos), considera fixtures que resetean el estado entre tests para evitar interferencia. Por ejemplo:
@pytest.fixture(autouse=True)
def reset_storage():
"""Opcional: resetear almacenamiento in-memory antes de cada test."""
yield
# Teardown: limpiar stores si es necesario
conftest.py de Integration (tests/integration/conftest.py)
# tests/integration/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def auth_headers(client):
"""Registra usuario, hace login, retorna headers con Bearer token."""
client.post("/auth/register", json={
"email": "test@example.com",
"password": "SecurePass123!"
})
response = client.post("/auth/login", json={
"email": "test@example.com",
"password": "SecurePass123!"
})
token = response.json()["token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def team_id(client, auth_headers):
"""Crea un equipo y retorna su ID."""
response = client.post("/teams", json={"name": "Test Team"}, headers=auth_headers)
return response.json()["id"]
Con auth_headers y team_id, los tests de integration pueden enfocarse en el comportamiento del endpoint sin repetir setup.
conftest.py de Unit (tests/unit/conftest.py)
Para unit tests, típicamente inyectas dependencias o usas mocks:
# tests/unit/conftest.py
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "email": "alice@example.com"}
@pytest.fixture
def sample_team():
return {"id": 1, "name": "Dev Team", "owner_id": 1, "member_ids": [1]}
Mocks para Servicios Externos
Cuándo mockear
TaskFlow API usa almacenamiento in-memory, pero si añades un feature como "enviar email cuando se asigna una tarea", ese envío es un servicio externo. En tests, no quieres enviar emails reales. Mockeas el cliente de email.
Ejemplo: servicio de notificaciones
Supón que agregas NotificationService que envía emails:
# app/notifications/service.py (hipotético)
class NotificationService:
def send_assignment_email(self, to_email: str, task_title: str) -> bool:
# Llama a API externa o SMTP
...
En tests:
# tests/unit/test_tasks.py
def test_assign_task_triggers_notification(mocker):
mock_notif = mocker.patch("app.tasks.service.NotificationService.send_assignment_email")
service = TaskService(notification_service=NotificationService())
service.assign_task(task_id=1, user_id=2)
mock_notif.assert_called_once()
O con dependency injection en FastAPI, inyectas un mock en el cliente de test.
Prompt para Claude Code
En TaskFlow, si añadimos notificaciones por email al asignar tareas,
necesito mockear el servicio de email en tests. Muéstrame cómo usar
pytest-mock para parchear NotificationService.send_email y evitar
llamadas reales.
Tests E2E de Flujos Completos
Qué testean los E2E
Los E2E validan un flujo de usuario completo de punta a punta: registro → login → crear equipo → crear tarea → asignar → completar. Un solo test que recorre toda la API como haría un cliente real.
Ejemplo: flujo completo
# tests/e2e/test_flows.py
def test_full_user_flow_register_login_create_team_task_complete(client):
"""E2E: Usuario se registra, hace login, crea equipo, crea tarea, la asigna y completa."""
# Register
r1 = client.post("/auth/register", json={
"email": "e2e@test.com",
"password": "SecurePass123!"
})
assert r1.status_code == 201
# Login
r2 = client.post("/auth/login", json={
"email": "e2e@test.com",
"password": "SecurePass123!"
})
assert r2.status_code == 200
token = r2.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# Create team
r3 = client.post("/teams", json={"name": "E2E Team"}, headers=headers)
assert r3.status_code == 201
team_id = r3.json()["id"]
# Create task
r4 = client.post(f"/teams/{team_id}/tasks", json={
"title": "E2E Task",
"status": "pending"
}, headers=headers)
assert r4.status_code == 201
task_id = r4.json()["id"]
# Update to in_progress
r5 = client.patch(f"/tasks/{task_id}", json={"status": "in_progress"}, headers=headers)
assert r5.status_code == 200
# Complete
r6 = client.patch(f"/tasks/{task_id}", json={"status": "completed"}, headers=headers)
assert r6.status_code == 200
assert r6.json()["status"] == "completed"
Por qué E2E importan
Detectan problemas de integración que unit e integration pueden pasar por alto: orden de dependencias, tokens expirados en medio del flujo, rutas mal configuradas. Un E2E que pasa da confianza de que la app funciona de principio a fin.
Configurar el Pipeline CI en GitHub Actions
Workflow básico
Crea .github/workflows/tests.yml:
name: Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with coverage
run: |
pytest tests/ -v --cov=app --cov-report=term-missing --cov-report=xml --cov-fail-under=90
Explicación de flags
--cov=app: mide coverage del directorioapp/--cov-report=term-missing: muestra líneas sin cover en la salida--cov-report=xml: generacoverage.xmlpara integración con herramientas--cov-fail-under=90: falla el job si coverage < 90%
Matrix testing (opcional)
Para validar en varias versiones de Python:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
Caching de dependencias
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
Verificar el Pipeline
Checklist de validación
Después de configurar el workflow:
- Haz push a tu branch
- Ve a la pestaña Actions en GitHub
- El job "test" debe ejecutarse
- Los tests deben pasar
- El coverage debe mostrarse en los logs
- Si coverage < 90%, el job debe fallar (con
--cov-fail-under=90)
Probar que falla correctamente
Introduce un test que falle o elimina temporalmente un test que cubría código importante. El pipeline debe ponerse rojo. Luego revierte. Esto confirma que el CI protege el código.
Coverage Final: Objetivo ≥90%
Comando local
pytest tests/ -v --cov=app --cov-report=term-missing --cov-report=html
term-missing: líneas no cubiertas en consolahtml: abrehtmlcov/index.htmlpara ver informe visual
Qué hacer si no llegas a 90%
- Revisa el informe: identifica archivos con bajo coverage
- Escribe tests para los branches y líneas faltantes
- Usa prompts a Claude Code: "Analiza app/tasks/rules.py y genera tests para cubrir las líneas 45-60 que validan transiciones de estado"
pyproject.toml para coverage
[tool.coverage.run]
source = ["app"]
omit = ["tests/*", "app/__init__.py"]
[tool.coverage.report]
fail_under = 90
show_missing = true
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError"
]
Badge de CI en README
Añade al README:

Reemplaza YOUR-USERNAME y YOUR-REPO con tu org/repo. El badge muestra el estado del último run (passing/failing).
Resumen
- Jerarquía de conftest: raíz (fixtures globales), unit, integration, e2e
- Mocks para servicios externos (email, APIs) evitan efectos secundarios en tests
- E2E validan flujos completos de usuario
- GitHub Actions corre tests en cada push/PR
- Coverage ≥90% con
--cov-fail-under=90 - Badge de CI en README para portfolio
Próxima cápsula: Entrega y Retrospectiva — rúbrica final, preparación para portfolio, y cierre del módulo.
Módulo 8, Cápsula 05 — Testing with Claude Code Guide