Módulo 3: Integration y E2E Tests

E2E Testing: Flujos Completos de API

E2E Testing: Flujos Completos de API

Descripción de la cápsula

En las cápsulas anteriores testearon endpoints uno por uno: POST retorna 201, GET retorna 404 cuando no existe. Esos son integration tests — verifican interfaces. Pero un cliente real de tu API no llama a un solo endpoint. Llama a varios en secuencia: crear recurso → obtenerlo → actualizarlo → eliminarlo. Si el flujo completo falla en algún eslabón, los integration tests individuales pueden estar verdes y el usuario tener una experiencia rota.

Esta cápsula cubre E2E testing para APIs: validar flujos completos tal como los usaría un cliente HTTP real. No hablamos de Selenium ni Playwright — aquí E2E significa backend end-to-end: la cadena completa request → routing → handler → lógica → base de datos → response, pero atravesando múltiples requests que forman una historia de usuario. Al final podrás escribir E2E tests con TestClient, organizarlos correctamente, y evitar la trampa del flakiness.


Qué Significa E2E para APIs

Testing del flujo completo como cliente real

Un E2E test de API simula lo que hace un consumidor de tu API cuando ejecuta una operación completa:

Cliente real (Postman, frontend, otra API):
1. POST /users     → Crea usuario
2. POST /login     → Obtiene token
3. POST /orders    → Crea orden (con token)
4. GET  /orders/1  → Lee la orden
5. PATCH /orders/1 → Actualiza estado
6. GET  /orders/1  → Verifica el cambio

Un integration test verifica solo uno de esos pasos aislado: "POST /orders retorna 201 con el body correcto". Un E2E test verifica que toda la secuencia funciona de principio a fin. Si en el paso 4 la orden no existe (por un bug en el paso 3), el integration de POST podría estar verde y el flujo real romperse.

La cadena completa: request → response

En un E2E test de API verificas:

  • Request HTTP llega correctamente al servidor
  • Routing dirige a la función correcta
  • Handler procesa el request
  • Lógica de negocio se ejecuta
  • Base de datos persiste o lee datos
  • Response vuelve con el formato esperado

Y lo haces para varios requests encadenados. Cada request puede depender del resultado del anterior (por ejemplo, el id devuelto en un POST se usa en el GET siguiente).

No es testing de browser

En esta guía, E2E = flujos de API, no testing de UI. No usas Selenium, Playwright ni headless browsers. El cliente es TestClient de FastAPI o httpx — pura HTTP contra tu backend. El frontend no interviene.


E2E vs Integration: La Diferencia Clave

Integration: un endpoint en aislamiento

Integration test típico:
- POST /items con json válido → assert 201
- Verificar que el body tiene id, name, price
- (Opcional) Verificar que existe en DB

Un solo request. Un solo endpoint. El test pasa o falla según ese punto de contacto.

E2E: un flujo de múltiples endpoints

E2E test típico:
1. POST /items → 201, guardar id
2. GET /items/{id} → 200, mismo name
3. PUT /items/{id} → 200, name actualizado
4. DELETE /items/{id} → 200
5. GET /items/{id} → 404 (recurso eliminado)

Varios requests en secuencia. Cada uno depende del anterior. Si alguno falla, el test falla — y sabes que hay un problema en el flujo completo, no solo en un endpoint.

Tabla comparativa

AspectoIntegrationE2E
Requests por test1 (a veces 2 para setup)3-10+
Qué verificaLa interfaz del endpointEl workflow de usuario
VelocidadRápidoMás lento
ConfianzaEndpoint funcionaFlujo completo funciona
MantenimientoBajoMayor
FlakinessBajoAlto si no cuidas el setup

Regla práctica

  • Integration verifica que las piezas encajan (endpoint ↔ DB, endpoint ↔ servicio).
  • E2E verifica que el flujo de negocio funciona de punta a punta.

Escribir E2E Tests con TestClient

Setup mínimo

Necesitas la app FastAPI y un cliente. Si usas base de datos, cada test debe partir de un estado limpio (o usar transacciones/DB en memoria).

# conftest.py (o en el archivo de tests)
import pytest
from fastapi.testclient import TestClient
from main import app

@pytest.fixture
def client():
    return TestClient(app)

Ejemplo: ciclo de vida CRUD completo

# tests/e2e/test_item_lifecycle.py
import pytest
from fastapi.testclient import TestClient
from main import app, items_db  # asumiendo store en memoria o DB de test

client = TestClient(app)


def setup_function():
    """Limpia el estado antes de cada test (evita flakiness)."""
    items_db.clear()


class TestItemLifecycle:
    """E2E: Ciclo de vida CRUD completo para items."""

    def test_full_item_crud_lifecycle(self):
        # CREATE
        create_response = client.post(
            "/items",
            json={"name": "E2E Item", "price": 42.0}
        )
        assert create_response.status_code == 201
        data = create_response.json()
        item_id = data["id"]

        # READ
        get_response = client.get(f"/items/{item_id}")
        assert get_response.status_code == 200
        assert get_response.json()["name"] == "E2E Item"

        # UPDATE
        update_response = client.put(
            f"/items/{item_id}",
            json={"name": "Updated", "price": 99.0}
        )
        assert update_response.status_code == 200
        assert update_response.json()["name"] == "Updated"

        # DELETE
        delete_response = client.delete(f"/items/{item_id}")
        assert delete_response.status_code == 200  # o 204 según tu API

        # VERIFY DELETED
        verify_response = client.get(f"/items/{item_id}")
        assert verify_response.status_code == 404

Un solo test que valida el flujo completo. Si falla, sabes que algo en la cadena CRUD está roto.

Código de la app de ejemplo (completo)

# main.py — API mínima para que los tests funcionen
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()
items_db: dict[int, dict] = {}
_id_counter = 0


class ItemCreate(BaseModel):
    name: str
    price: float


class ItemUpdate(BaseModel):
    name: str | None = None
    price: float | None = None


class ItemResponse(BaseModel):
    id: int
    name: str
    price: float


@app.post("/items", response_model=ItemResponse)
def create_item(item: ItemCreate):
    global _id_counter
    _id_counter += 1
    obj = {"id": _id_counter, "name": item.name, "price": item.price}
    items_db[_id_counter] = obj
    return obj


@app.get("/items/{item_id}", response_model=ItemResponse)
def get_item(item_id: int):
    if item_id not in items_db:
        raise HTTPException(404, "Item not found")
    return items_db[item_id]


@app.put("/items/{item_id}", response_model=ItemResponse)
def update_item(item_id: int, item: ItemUpdate):
    if item_id not in items_db:
        raise HTTPException(404, "Item not found")
    obj = items_db[item_id]
    if item.name is not None:
        obj["name"] = item.name
    if item.price is not None:
        obj["price"] = item.price
    return obj


@app.delete("/items/{item_id}")
def delete_item(item_id: int):
    if item_id not in items_db:
        raise HTTPException(404, "Item not found")
    del items_db[item_id]
    return {"status": "deleted"}

Patrones E2E Comunes

1. Registro → login → recurso protegido

def test_user_registration_login_and_protected_access(self, client):
    # 1. Registrar
    reg = client.post("/auth/register", json={
        "email": "e2e@test.com",
        "password": "secure123"
    })
    assert reg.status_code == 201

    # 2. Login
    login = client.post("/auth/login", json={
        "email": "e2e@test.com",
        "password": "secure123"
    })
    assert login.status_code == 200
    token = login.json()["access_token"]

    # 3. Acceso protegido
    headers = {"Authorization": f"Bearer {token}"}
    resp = client.get("/me", headers=headers)
    assert resp.status_code == 200
    assert resp.json()["email"] == "e2e@test.com"

2. Parent → child → parent con hijos

def test_create_parent_then_child_then_fetch_parent_with_children(self, client):
    parent = client.post("/categories", json={"name": "Electronics"})
    parent_id = parent.json()["id"]

    child = client.post("/categories", json={
        "name": "Phones",
        "parent_id": parent_id
    })
    assert child.status_code == 201

    parent_with_children = client.get(f"/categories/{parent_id}?include_children=true")
    assert len(parent_with_children.json()["children"]) == 1

3. Idempotencia

def test_double_create_with_same_idempotency_key_returns_same_resource(self, client):
    key = "idem-123"
    r1 = client.post("/orders", json={"items": [1, 2]}, headers={"Idempotency-Key": key})
    r2 = client.post("/orders", json={"items": [1, 2]}, headers={"Idempotency-Key": key})
    assert r1.status_code == 201
    assert r2.status_code == 200  # o 201 si es idempotent create
    assert r1.json()["id"] == r2.json()["id"]

4. Flujos de error

def test_create_invalid_then_fix_then_succeed(self, client):
    # Intento inválido
    r1 = client.post("/items", json={"name": "", "price": -1})
    assert r1.status_code == 422

    # Correcto
    r2 = client.post("/items", json={"name": "Valid", "price": 10.0})
    assert r2.status_code == 201

    # Recuperar inexistente
    r3 = client.get("/items/99999")
    assert r3.status_code == 404

Cuándo Vale la Pena el Costo de E2E

Dónde invertir

  • Rutas críticas de negocio: checkout, registro, pago, onboarding.
  • Flujos que cruzan varios componentes: auth + CRUD + notificaciones.
  • Protección de regresiones: bugs que ya ocurrieron y quieres que no vuelvan.
  • Contratos con clientes externos: si otra API depende de tu flujo completo.

Dónde no invertir

  • Cada variación de cada endpoint: eso son integration tests.
  • Edge cases de lógica pura: eso son unit tests.
  • Escenarios hipotéticos poco probables: el ROI no compensa.

Regla práctica

Tener 2-5 flujos E2E por proyecto suele ser suficiente. Más que eso y la suite se vuelve lenta y frágil. Los integration tests cubren el resto.

Checklist: ¿E2E o integration?

¿El comportamiento que quieres verificar requiere múltiples requests encadenados?
   → Sí: E2E
   → No: Integration

¿El valor está en que el flujo completo funcione (registro → login → recurso)?
   → Sí: E2E
   → No: Integration

¿Solo necesitas verificar que un endpoint responde correctamente?
   → Integration

¿Quieres protección contra regresiones en un flujo crítico de negocio?
   → E2E

Organizar Tests E2E

Estructura de directorios

tests/
├── unit/
├── integration/
└── e2e/
    ├── test_item_lifecycle.py
    ├── test_user_auth_flow.py
    └── test_order_checkout_flow.py

Convenciones de nombres

  • Archivo: un archivo por flujo o historia de usuario.
  • Tests: test_* descriptivos que explican el flujo.

Ejemplos:

def test_full_item_crud_lifecycle(self): ...
def test_user_registration_flow(self): ...
def test_order_checkout_flow(self): ...
def test_create_parent_then_child_then_fetch_with_children(self): ...

Ejecutar E2E por separado (marcadores pytest)

Los E2E son más lentos. Puedes correrlos solo cuando haga falta:

# tests/e2e/test_item_lifecycle.py
import pytest

@pytest.mark.e2e
def test_full_crud_lifecycle(client):
    ...
# Solo unit e integration (rápido)
pytest -m "not e2e"

# Solo E2E
pytest -m e2e

# Todo
pytest

En pytest.ini o pyproject.toml:

[pytest]
markers =
    e2e: End-to-end tests (slower, full flows)

Claude Code para Generar E2E Tests

Prompt base

Genera E2E tests que validen el flujo completo de [descripción del flujo].
Cada test debe simular un usuario real usando la API de principio a fin.
Usa FastAPI TestClient. No uses Selenium ni Playwright — solo HTTP.
Incluye setup para limpiar el estado entre tests (ej. clear DB o fixture).

Ejemplo concreto

Genera E2E tests para el flujo CRUD de items en mi API FastAPI.
Flujo: POST crear item → GET por id → PUT actualizar → DELETE → GET verificar 404.
Usa TestClient. Un test por flujo completo.
Incluye setup_function que limpie items_db antes de cada test.

Diferencias con integration

NivelPrompt típico
Integration"Genera integration tests para POST /items. Un test por endpoint, verifica status y body."
E2E"Genera un E2E test que valide el flujo completo: crear → leer → actualizar → eliminar → verificar eliminado."

La Trampa del E2E: Flakiness

Qué es el flakiness

Tests que a veces pasan y a veces fallan sin cambiar el código. Es más frecuente en E2E porque intervienen más componentes.

Causas habituales

  • Estado compartido: tests que modifican la misma DB o el mismo recurso.
  • Orden de ejecución: un test que depende de datos creados por otro.
  • Timing: timeouts, delays, condiciones de carrera.
  • Datos no deterministas: IDs, timestamps, aleatoriedad.

Cómo evitarlo

  1. Estado limpio por test: setup_function o fixture que vacíe/resetee DB.
  2. Tests independientes: cada test crea sus propios datos.
  3. Evitar orden de ejecución: ningún test depende de otro.
  4. Fixtures aislados: cada test tiene su cliente, DB, etc.
  5. Evitar sleep(): usar polling o mocks para tiempo.

Ejemplo de anti-patrón

# MAL: estado compartido entre tests
items_db = []  # global

def test_create_item():
    client.post("/items", json={"name": "A"})  # id=1
    # Si otro test ya creó items, el id puede no ser 1

def test_get_item():
    r = client.get("/items/1")  # Asume que existe id=1
    assert r.status_code == 200  # Falla si test_create_item no corrió antes

Patrón correcto

def setup_function():
    items_db.clear()

def test_full_lifecycle():
    create = client.post("/items", json={"name": "A"})
    item_id = create.json()["id"]  # Usa el id del response
    # ... resto del flujo con item_id

Fixtures para E2E con Base de Datos Real

Cuando tu API usa PostgreSQL, SQLite u otra DB, los E2E necesitan un estado limpio sin afectar datos de desarrollo.

Opción 1: DB en memoria (SQLite)

# conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base, get_db
from main import app

engine = create_engine(
    "sqlite:///:memory:",
    connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


def override_get_db():
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()


@pytest.fixture(scope="function")
def client():
    Base.metadata.create_all(bind=engine)
    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as c:
        yield c
    Base.metadata.drop_all(bind=engine)
    app.dependency_overrides.clear()

Cada test usa una DB vacía en memoria. Rápido y aislado.

Opción 2: Transacciones que se revierten

from sqlalchemy.orm import Session

@pytest.fixture
def db_session():
    """Crea una transacción que se hace rollback después del test."""
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    yield session
    session.close()
    transaction.rollback()
    connection.close()

Cada test corre en una transacción que nunca se hace commit. Al final, rollback — la DB queda igual que al inicio.

Opción 3: Limpiar tablas en setup

from sqlalchemy import text

def setup_function():
    with engine.connect() as conn:
        conn.execute(text("DELETE FROM items"))
        conn.execute(text("DELETE FROM categories"))
        conn.commit()

Más simple pero menos aislado si hay foreign keys o triggers. Úsalo cuando no tengas transacciones disponibles.


Integración vs E2E: Comparación Lado a Lado

Misma funcionalidad (CRUD de items), dos enfoques:

Integration: endpoints aislados

# tests/integration/test_items_api.py
def test_post_items_returns_201(client):
    r = client.post("/items", json={"name": "Item", "price": 10.0})
    assert r.status_code == 201
    assert "id" in r.json()

def test_get_item_returns_200(client):
    create = client.post("/items", json={"name": "X", "price": 5.0})
    item_id = create.json()["id"]
    r = client.get(f"/items/{item_id}")
    assert r.status_code == 200

def test_put_item_returns_200(client):
    create = client.post("/items", json={"name": "Y", "price": 1.0})
    item_id = create.json()["id"]
    r = client.put(f"/items/{item_id}", json={"name": "Z", "price": 2.0})
    assert r.status_code == 200

def test_delete_item_returns_200(client):
    create = client.post("/items", json={"name": "W", "price": 0.0})
    item_id = create.json()["id"]
    r = client.delete(f"/items/{item_id}")
    assert r.status_code == 200

4 tests, cada uno verifica un endpoint. Si PUT falla pero POST y GET funcionan, solo falla test_put_item. No comprueban que el flujo completo sea coherente.

E2E: flujo completo

# tests/e2e/test_item_lifecycle.py
def test_full_crud_lifecycle(client):
    # Todo en un solo test, orden real de uso
    create = client.post("/items", json={"name": "E2E", "price": 42.0})
    assert create.status_code == 201
    item_id = create.json()["id"]

    get1 = client.get(f"/items/{item_id}")
    assert get1.status_code == 200
    assert get1.json()["name"] == "E2E"

    update = client.put(f"/items/{item_id}", json={"name": "Updated", "price": 99.0})
    assert update.status_code == 200

    get2 = client.get(f"/items/{item_id}")
    assert get2.json()["name"] == "Updated"

    delete = client.delete(f"/items/{item_id}")
    assert delete.status_code == 200

    get3 = client.get(f"/items/{item_id}")
    assert get3.status_code == 404

1 test que valida el flujo completo. Si DELETE no borra realmente y GET sigue devolviendo 200, este test falla. Los integration tests individuales podrían seguir en verde.


Ejercicios

Ejercicio 1: Diferenciar Integration y E2E (Fácil)

Indica si cada descripción corresponde a un test de Integration o E2E:

  1. Verifica que POST /users retorna 201 con el usuario en el body.
  2. Verifica que tras registrar usuario, hacer login y crear un post, el post aparece en GET /users/me/posts.
  3. Verifica que GET /products/999 retorna 404.
  4. Verifica el flujo: crear categoría → crear producto con esa categoría → obtener categoría con productos incluidos.
Ver solución
  1. Integration — Un solo endpoint, un solo request.
  2. E2E — Secuencia: registrar → login → crear post → GET.
  3. Integration — Un solo GET.
  4. E2E — Flujo de múltiples requests con dependencia entre ellos.

Ejercicio 2: E2E para flujo de auth (Medio)

Tienes endpoints: POST /register, POST /login, GET /me (protegido con Bearer). Escribe un E2E test que valide: registro → login → acceso a /me con el token.

Ver solución
def test_register_login_and_access_me(client):
    # 1. Register
    reg = client.post("/register", json={
        "email": "e2e@test.com",
        "password": "secret123"
    })
    assert reg.status_code == 201

    # 2. Login
    login = client.post("/login", json={
        "email": "e2e@test.com",
        "password": "secret123"
    })
    assert login.status_code == 200
    token = login.json()["access_token"]

    # 3. Access protected resource
    me = client.get("/me", headers={"Authorization": f"Bearer {token}"})
    assert me.status_code == 200
    assert me.json()["email"] == "e2e@test.com"

Ejercicio 3: Setup para evitar flakiness (Medio)

Un E2E test falla aleatoriamente. Sospechas estado compartido. El test crea items y a veces falla en assert create.json()["id"] == 1. ¿Qué cambios harías?

Ver solución

Problema: Asume que el primer item creado tiene id == 1. Si otros tests o ejecuciones previas dejaron datos, el id puede ser mayor.

Cambios:

  1. Limpieza antes del test:
def setup_function():
    items_db.clear()  # o truncate de la tabla en DB real
  1. No asumir id fijo:
create = client.post("/items", json={"name": "X", "price": 1.0})
item_id = create.json()["id"]  # Usa el id devuelto
# Usa item_id en el resto del test
  1. Fixtures aislados: Asegurar que cada test tenga su propia DB o transacción que se revierte.

Ejercicio 4: Escribir un E2E completo (Medio)

Implementa un E2E test para una API de tasks con POST /tasks, GET /tasks/{id}, PATCH /tasks/{id}, DELETE /tasks/{id}. El flujo: crear → leer → actualizar (marcar completed) → eliminar → verificar 404.

Ver solución
def test_full_task_lifecycle(client):
    # Create
    create = client.post("/tasks", json={"title": "E2E task"})
    assert create.status_code == 201
    task_id = create.json()["id"]

    # Read
    get1 = client.get(f"/tasks/{task_id}")
    assert get1.status_code == 200
    assert get1.json()["title"] == "E2E task"
    assert get1.json()["completed"] is False

    # Update
    patch = client.patch(f"/tasks/{task_id}", json={"completed": True})
    assert patch.status_code == 200
    assert patch.json()["completed"] is True

    # Delete
    delete = client.delete(f"/tasks/{task_id}")
    assert delete.status_code in (200, 204)

    # Verify gone
    get2 = client.get(f"/tasks/{task_id}")
    assert get2.status_code == 404

Ejercicio 5: Prompt para Claude Code (Fácil)

Quieres que Claude Code genere E2E tests para el flujo de checkout: agregar items al carrito → aplicar cupón → confirmar orden → verificar que la orden existe. Escribe el prompt.

Ver solución
Genera E2E tests que validen el flujo completo de checkout de mi API.
Flujo: POST /cart/items (agregar items) → POST /cart/coupon (aplicar cupón) → POST /orders (confirmar) → GET /orders/{id} (verificar orden creada).
Usa FastAPI TestClient. Un test por flujo completo.
Cada test debe crear su propio carrito (o usar setup que limpie el estado).
No uses Selenium ni Playwright — solo HTTP contra la API.

Ejercicio 6: ¿E2E o Integration? (Medio)

Para cada escenario, di si elegirías E2E o Integration y por qué:

  • a) Verificar que un cambio en la validación de email en registro rompe el flujo registro → login.
  • b) Verificar que PUT /users/{id} retorna 400 cuando el email ya existe.
  • c) Verificar que tras crear 10 órdenes, GET /orders devuelve las 10.
Ver solución
  • a) E2E — El flujo completo (registro → login) es lo que importa. Un integration de POST /register podría no cubrir bien la interdependencia.
  • b) Integration — Un solo endpoint, un solo request con caso de error. No necesitas encadenar requests.
  • c) Integration o E2E — Depende. Si solo verificas que GET devuelve lo creado, es integration (crear + GET). Si verificas un flujo más amplio (crear → pagar → listar órdenes completadas), sería E2E. Para "crear 10 y listar" suele bastar integration.

Conexión con Proyecto

El proyecto del módulo requiere una test pyramid completa para una API REST de items. Según la cápsula 01, debes incluir:

  • Unit tests para lógica de negocio.
  • Integration tests para cada endpoint.
  • Al menos 2 E2E tests que validen flujos completos.

Ejemplos de E2E que encajan:

  1. Flujo CRUD de items: crear → leer → actualizar → eliminar → verificar 404.
  2. Flujo con categorías (si aplica): crear categoría → crear item con categoría → obtener item con categoría.

Claude Code puede generar ambos con prompts como los de esta cápsula. Asegúrate de tener setup (p. ej. setup_function o fixtures) que limpien el estado entre tests para evitar flakiness.


Troubleshooting

1. El E2E falla solo a veces (flaky)

Causa: Estado compartido, orden de ejecución o timing.

Solución: Añade setup_function o fixture que vacíe la DB/storage antes de cada test. Asegúrate de que cada test cree sus propios datos y no dependa de otros tests. Evita sleep(); si necesitas esperar, usa polling o mocks.


2. El test asume ids fijos (1, 2, 3…)

Causa: Código que usa client.get("/items/1") asumiendo que siempre existirá.

Solución: Usa siempre el id devuelto por el request anterior: item_id = create.json()["id"] y luego client.get(f"/items/{item_id}").


3. Los E2E tardan demasiado

Causa: Demasiados E2E o setup pesado (DB real, servicios externos).

Solución: Reduce a 2–5 flujos E2E críticos. Usa DB en memoria o SQLite para tests. Mueve variaciones de endpoints a integration tests.


4. Claude Code genera tests de Selenium/Playwright

Causa: El prompt no aclara que E2E es solo API, no UI.

Solución: Sé explícito: "E2E para API: flujos HTTP completos con TestClient. No uses Selenium, Playwright ni browser. Solo requests HTTP contra el backend."


5. El test pasa pero el flujo falla en producción

Causa: TestClient no pasa por el mismo stack que producción (proxy, load balancer, middleware, CORS).

Solución: Los E2E con TestClient cubren la app tal como la tienes montada en tests. Para detectar problemas de infraestructura, necesitarías tests contra un entorno más cercano a producción (staging). Para la mayoría de proyectos, E2E con TestClient es suficiente para validar la lógica del flujo.


Resumen

  • ✅ E2E para APIs = flujos completos vía HTTP, no testing de browser; no Selenium ni Playwright
  • ✅ Integration verifica un endpoint aislado; E2E verifica una secuencia de requests como cliente real
  • ✅ E2E con TestClient: crear → leer → actualizar → eliminar → verificar eliminación
  • ✅ Patrones habituales: registro → login → recurso protegido; parent → child → parent con hijos; idempotencia; flujos de error
  • ✅ Usa E2E en flujos críticos (2–5 por proyecto); el resto con integration y unit
  • ✅ Organiza en tests/e2e/ con nombres que describan el flujo
  • ✅ Claude Code genera E2E con prompts que especifican "flujo completo" y "solo HTTP"
  • ✅ Flakiness: evitar estado compartido, limpiar en setup, tests independientes, no asumir ids fijos

Próxima cápsula: Proyecto — Test pyramid completa (Cápsula 06).


Recursos Adicionales

  1. FastAPI: Testing — Documentación oficial de TestClient
  2. Martin Fowler: E2E Testing — Broad stack tests vs narrow
  3. Google: Just Say No to More E2E Tests — Cuándo no abusar de E2E
  4. pytest: Fixing flaky tests — Estrategias para tests inestables
  5. httpx: Async client — Cliente HTTP para tests más avanzados
  6. Test Pyramid vs Ice Cream Cone — Por qué no invertir la pyramid con demasiados E2E

Módulo 3, Cápsula 05 — Testing with Claude Code Guide