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 directorio app/
  • --cov-report=term-missing: muestra líneas sin cover en la salida
  • --cov-report=xml: genera coverage.xml para 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:

  1. Haz push a tu branch
  2. Ve a la pestaña Actions en GitHub
  3. El job "test" debe ejecutarse
  4. Los tests deben pasar
  5. El coverage debe mostrarse en los logs
  6. 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 consola
  • html: abre htmlcov/index.html para ver informe visual

Qué hacer si no llegas a 90%

  1. Revisa el informe: identifica archivos con bajo coverage
  2. Escribe tests para los branches y líneas faltantes
  3. 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:

![Tests](https://github.com/YOUR-USERNAME/YOUR-REPO/actions/workflows/tests.yml/badge.svg)

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