Módulo 3: Integration y E2E Tests

API Testing con FastAPI TestClient

API Testing con FastAPI TestClient

Descripción de la cápsula

En la cápsula anterior comprendiste la test pyramid y los trade-offs entre niveles. Ahora toca bajar a la práctica: ¿cómo testeas los endpoints de tu API FastAPI sin levantar un servidor real? ¿Cómo verificas que un GET /items retorna 200 con una lista, que un POST /items crea el recurso y retorna 201, o que un endpoint protegido rechaza requests sin token?

El FastAPI TestClient es la herramienta estándar para integration testing de APIs: envuelve tu aplicación, envía requests HTTP reales (en proceso, sin red), y te devuelve responses que puedes verificar con assertions normales. Esta cápsula te enseña a configurarlo, testear GET/POST/PUT/DELETE, manejar autenticación, usar fixtures para el cliente, y cómo pedirle a Claude Code que genere integration tests para tus endpoints.

Al final tendrás un conjunto de patrones claros que aplicarás en el proyecto del módulo: integration tests para todos los endpoints CRUD.


¿Qué es el Integration Testing para APIs?

Testing HTTP como cliente real

Un integration test para APIs verifica que los endpoints funcionan de extremo a extremo desde la perspectiva de un cliente HTTP. No pruebas funciones aisladas — pruebas la cadena completa: request HTTP → router → handler → lógica de negocio → response HTTP.

Eso implica verificar:

  • Status codes: 200, 201, 204, 400, 401, 404, 422...
  • Response body: estructura JSON, campos esperados, valores correctos
  • Headers: Content-Type, headers personalizados, cookies
  • Interacciones entre componentes: que el router llama al handler correcto, que la validación Pydantic rechaza payloads inválidos

Por qué no basta con unit tests

Un unit test puede verificar que calculate_total(items) retorna 42. Pero no verifica que el endpoint POST /orders reciba el JSON correcto, aplique esa función, y retorne {"total": 42} con status 201. Esa integración router → handler → lógica es lo que cubren los integration tests.


Contexto: App FastAPI de Ejemplo

Antes de seguir, aquí tienes una app FastAPI simple que usarás como referencia en todos los ejemplos. Es un CRUD de items en memoria.

# main.py
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel, Field
from typing import Optional

app = FastAPI(title="Items API")

# Almacenamiento en memoria para simplificar
items_db: dict[int, dict] = {}
next_id = 1


class ItemCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    price: float = Field(..., ge=0, le=999999.99)
    description: Optional[str] = None


class ItemResponse(BaseModel):
    id: int
    name: str
    price: float
    description: Optional[str] = None


@app.get("/items", response_model=list[ItemResponse])
def list_items():
    """Listar todos los items."""
    return [ItemResponse(**item) for item in items_db.values()]


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


@app.post("/items", response_model=ItemResponse, status_code=201)
def create_item(item: ItemCreate):
    """Crear un nuevo item."""
    global next_id
    item_data = {"id": next_id, **item.model_dump()}
    items_db[next_id] = item_data
    next_id += 1
    return ItemResponse(**item_data)


@app.put("/items/{item_id}", response_model=ItemResponse)
def update_item(item_id: int, item: ItemCreate):
    """Actualizar un item existente."""
    if item_id not in items_db:
        raise HTTPException(status_code=404, detail="Item not found")
    items_db[item_id].update(item.model_dump())
    return ItemResponse(**items_db[item_id])


@app.delete("/items/{item_id}", status_code=204)
def delete_item(item_id: int):
    """Eliminar un item."""
    if item_id not in items_db:
        raise HTTPException(status_code=404, detail="Item not found")
    del items_db[item_id]


# Endpoint protegido para ejemplos de auth
FAKE_TOKEN = "secret-token-123"


def verify_token(authorization: Optional[str] = None):
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing or invalid token")
    token = authorization.split(" ")[1]
    if token != FAKE_TOKEN:
        raise HTTPException(status_code=401, detail="Invalid token")
    return token


@app.get("/admin/users")
def list_admin_users(auth: str = Depends(verify_token)):
    """Lista usuarios (protegido)."""
    return {"users": ["admin@example.com"], "token_used": auth}

Con esta app tendrás endpoints para listar, obtener, crear, actualizar y eliminar items, más un endpoint protegido para practicar autenticación.


Setup del FastAPI TestClient

Configuración básica

El TestClient de FastAPI viene de starlette.testclient (FastAPI está construido sobre Starlette). Lo usas así:

from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

TestClient(app) recibe tu instancia de FastAPI y crea un cliente que envía requests HTTP contra esa app.

Características importantes

  • No levanta servidor: Los requests se ejecutan en proceso. No hay red, no hay puerto. Es muy rápido.
  • Interfaz síncrona: Aunque FastAPI sea async, TestClient expone .get(), .post(), etc. de forma síncrona. Internamente usa httpx y se encarga de ejecutar la app en un event loop.
  • Requests HTTP reales: El cliente envía requests como lo haría curl o un browser. Pydantic valida, los middlewares se ejecutan, todo el stack de FastAPI funciona.

Estructura mínima de un archivo de tests

# tests/integration/test_items_api.py
import pytest
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)


def test_list_items_returns_200():
    response = client.get("/items")
    assert response.status_code == 200
    assert isinstance(response.json(), list)

Ejecutas con pytest tests/integration/test_items_api.py -v.


Testing GET Endpoints

Listar todos los items

def test_get_items_returns_200():
    response = client.get("/items")
    assert response.status_code == 200
    assert isinstance(response.json(), list)


def test_get_items_returns_empty_list_initially():
    response = client.get("/items")
    assert response.status_code == 200
    data = response.json()
    assert data == []

⚠️ En una app con DB compartida entre tests, el estado puede no ser "inicial". Por eso usas fixtures para aislar (ver más abajo). En la app en memoria, si no creas items, la lista estará vacía.

Obtener un item por ID

def test_get_item_by_id():
    # ARRANGE: crear un item primero
    create_response = client.post("/items", json={"name": "Test Item", "price": 29.99})
    assert create_response.status_code == 201
    item = create_response.json()
    item_id = item["id"]

    # ACT: obtener el item
    response = client.get(f"/items/{item_id}")

    # ASSERT
    assert response.status_code == 200
    data = response.json()
    assert data["id"] == item_id
    assert data["name"] == "Test Item"
    assert data["price"] == 29.99


def test_get_nonexistent_item_returns_404():
    response = client.get("/items/99999")
    assert response.status_code == 404
    data = response.json()
    assert "detail" in data
    assert "not found" in data["detail"].lower()

Verificar headers

def test_get_items_returns_json_content_type():
    response = client.get("/items")
    assert response.status_code == 200
    assert "application/json" in response.headers.get("content-type", "")

Verificar estructura del JSON

Más allá del status code, conviene verificar que el cuerpo tenga la forma esperada:

def test_get_item_returns_expected_schema():
    create_resp = client.post("/items", json={"name": "Schema Test", "price": 1.0})
    item_id = create_resp.json()["id"]

    response = client.get(f"/items/{item_id}")
    data = response.json()

    # Verificar que los campos del ItemResponse existen
    assert "id" in data
    assert "name" in data
    assert "price" in data
    assert isinstance(data["id"], int)
    assert isinstance(data["name"], str)
    assert isinstance(data["price"], (int, float))

Testing POST Endpoints

Crear item exitoso

def test_create_item_returns_201():
    payload = {"name": "New Item", "price": 49.99}
    response = client.post("/items", json=payload)

    assert response.status_code == 201
    data = response.json()
    assert data["name"] == "New Item"
    assert data["price"] == 49.99
    assert "id" in data
    assert isinstance(data["id"], int)


def test_create_item_with_description():
    payload = {"name": "Item with description", "price": 10.0, "description": "A test item"}
    response = client.post("/items", json=payload)

    assert response.status_code == 201
    data = response.json()
    assert data["description"] == "A test item"

Payload inválido → 422

Pydantic valida automáticamente. Si envías datos que no cumplen el schema, FastAPI retorna 422 Unprocessable Entity.

def test_create_item_empty_name_returns_422():
    response = client.post("/items", json={"name": ""})
    assert response.status_code == 422


def test_create_item_missing_required_field_returns_422():
    response = client.post("/items", json={"name": "Name only"})  # falta price
    assert response.status_code == 422


def test_create_item_negative_price_returns_422():
    response = client.post("/items", json={"name": "Item", "price": -10})
    assert response.status_code == 422


def test_create_item_invalid_json_returns_422():
    response = client.post("/items", json={"name": 123})  # name debe ser str
    assert response.status_code == 422

Inspeccionar el detalle del 422

Cuando un test de validación falle, el body del 422 de FastAPI incluye los errores de Pydantic:

def test_create_item_422_detail_structure():
    response = client.post("/items", json={"name": ""})
    assert response.status_code == 422
    data = response.json()
    # FastAPI devuelve {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}
    assert "detail" in data
    assert len(data["detail"]) > 0
    assert any("name" in str(err.get("loc", [])) for err in data["detail"])

Eso te sirve para escribir assertions más específicas o para depurar qué regla de validación falló.


Testing PUT y DELETE

PUT: actualizar item

def test_update_item_returns_200():
    # ARRANGE
    create_resp = client.post("/items", json={"name": "Original", "price": 5.0})
    item_id = create_resp.json()["id"]

    # ACT
    update_payload = {"name": "Updated", "price": 15.0}
    response = client.put(f"/items/{item_id}", json=update_payload)

    # ASSERT
    assert response.status_code == 200
    data = response.json()
    assert data["name"] == "Updated"
    assert data["price"] == 15.0
    assert data["id"] == item_id


def test_update_nonexistent_item_returns_404():
    response = client.put("/items/99999", json={"name": "X", "price": 1.0})
    assert response.status_code == 404

DELETE: eliminar y verificar

def test_delete_item_returns_204():
    create_resp = client.post("/items", json={"name": "To Delete", "price": 1.0})
    item_id = create_resp.json()["id"]

    response = client.delete(f"/items/{item_id}")
    assert response.status_code == 204
    assert response.content == b""


def test_delete_item_then_get_returns_404():
    create_resp = client.post("/items", json={"name": "To Delete", "price": 1.0})
    item_id = create_resp.json()["id"]

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


def test_delete_nonexistent_item_returns_404():
    response = client.delete("/items/99999")
    assert response.status_code == 404

Testing con Autenticación

Endpoint protegido sin token → 401

def test_protected_endpoint_without_token_returns_401():
    response = client.get("/admin/users")
    assert response.status_code == 401


def test_protected_endpoint_with_invalid_token_returns_401():
    response = client.get("/admin/users", headers={"Authorization": "Bearer wrong-token"})
    assert response.status_code == 401

Con token válido → 200

def test_protected_endpoint_with_valid_token():
    response = client.get(
        "/admin/users",
        headers={"Authorization": "Bearer secret-token-123"}
    )
    assert response.status_code == 200
    data = response.json()
    assert "users" in data
    assert "admin@example.com" in data["users"]

Fixture para token reutilizable

@pytest.fixture
def auth_headers():
    return {"Authorization": "Bearer secret-token-123"}


def test_admin_with_fixture(auth_headers):
    response = client.get("/admin/users", headers=auth_headers)
    assert response.status_code == 200

Fixtures para TestClient

Fixture del cliente

En vez de un client global, usas una fixture para que cada test tenga un cliente fresco (útil si la app tiene estado o conexiones):

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


def test_list_items_with_fixture(client):
    response = client.get("/items")
    assert response.status_code == 200

Fixture que crea datos (item pre-creado)

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


@pytest.fixture
def created_item(client):
    """Crea un item y lo retorna para uso en tests."""
    response = client.post("/items", json={"name": "Fixture Item", "price": 10.0})
    assert response.status_code == 201
    return response.json()


def test_get_item_using_fixture(client, created_item):
    item_id = created_item["id"]
    response = client.get(f"/items/{item_id}")
    assert response.status_code == 200
    assert response.json()["name"] == "Fixture Item"


def test_update_item_using_fixture(client, created_item):
    item_id = created_item["id"]
    response = client.put(f"/items/{item_id}", json={"name": "Updated", "price": 20.0})
    assert response.status_code == 200
    assert response.json()["name"] == "Updated"

Orden de fixtures

created_item depende de client. pytest resuelve el orden automáticamente: primero ejecuta client, luego created_item, y finalmente inyecta ambos en el test.


Claude Code: Prompt para Integration Tests

Prompt genérico

Cuando quieras que Claude Code genere integration tests para tus endpoints FastAPI, un prompt efectivo sería:

Genera integration tests para estos endpoints de FastAPI usando TestClient.

Contexto:
- App en main.py con CRUD de items
- Endpoints: GET /items, GET /items/{id}, POST /items, PUT /items/{id}, DELETE /items/{id}

Requisitos:
1. Usa from fastapi.testclient import TestClient y TestClient(app)
2. Para cada endpoint: tests de happy path (200/201/204) y tests de error (404, 422, 401 si aplica)
3. Usa arrange-act-assert
4. Verifica status_code, response.json(), y estructura esperada
5. Para POST/PUT verifica payload inválido retorna 422
6. Usa fixtures para el client y para items pre-creados cuando haga falta

Organiza en tests/integration/test_items_api.py

Prompt más específico

Genera integration tests para la API de items en main.py.

Cobertura requerida:
- GET /items: 200, lista vacía o con items
- GET /items/{id}: 200 cuando existe, 404 cuando no existe
- POST /items: 201 con payload válido, 422 con name vacío, price negativo, o campos faltantes
- PUT /items/{id}: 200 actualizando, 404 si no existe
- DELETE /items/{id}: 204 al eliminar, 404 si no existe

Usa pytest fixtures:
- client: TestClient(app)
- created_item: crea un item vía POST y retorna el JSON para tests que necesiten un item existente

Formato: tests/integration/test_items_api.py

Qué revisar en lo que genere Claude Code

  • ✅ Cada test verifica una sola cosa (un status, un campo)
  • ✅ Tests de error (404, 422) están incluidos
  • ✅ Se usa json= en POST/PUT, no data=
  • ✅ Los tests no dependen del orden de ejecución (estado aislado o fixtures)

Comparación: Unit vs Integration para APIs

AspectoUnit testIntegration test
Qué pruebaFunción aislada (ej. calculate_total)Endpoint completo (GET/POST/...)
DependenciasMockeadas o fixtures mínimasApp real, TestClient
VelocidadMuy rápidoRápido (sin red)
ConfianzaLógica interna correctaRequest → response correcto
Cuándo fallaBug en la funciónBug en router, validación, serialización

Los unit tests verifican la lógica. Los integration tests verifican que la API responde bien a requests HTTP.


Conexión con Proyecto

En el proyecto de este módulo (cápsula 06) construirás una test pyramid completa para una API REST de items. Los integration tests que escribas aquí — o que generes con Claude Code — son exactamente lo que necesitas en tests/integration/.

Criterios prácticos:

  • ✅ Integration tests para todos los endpoints CRUD: GET list, GET by id, POST, PUT, DELETE
  • ✅ Verificación de status codes (200, 201, 204, 404, 422)
  • ✅ Verificación de response body (campos, tipos)
  • ✅ Tests de payload inválido (422)
  • ✅ Uso de fixtures para client y created_item
  • ✅ Organización en tests/integration/test_items_api.py

La próxima cápsula (04) cubre database testing con fixtures. Cuando tu API use una DB real, necesitarás fixtures que configuran una test DB y la limpian después de cada test. Por ahora, la app en memoria y el TestClient son suficientes para dominar los patrones.


Troubleshooting

Problema 1: response.json() falla con "Expecting value"

Causa: El body de la response no es JSON válido (por ejemplo, vacío en 204, o HTML de error).

Solución: Verifica el status antes de llamar a .json(). Para 204 No Content, no hay body:

def test_delete_returns_204():
    response = client.delete("/items/1")
    assert response.status_code == 204
    # No llamar response.json() — el body está vacío
    assert response.content == b""

Si esperas JSON y falla, imprime response.text para ver qué devolvió el servidor.


Problema 2: Tests pasan aislados pero fallan al ejecutar la suite completa

Causa: Estado compartido entre tests. La app en memoria (items_db, next_id) persiste entre tests. Un test crea items que afectan a otros.

Solución: Resetea el estado al inicio de cada test o usa un fixture que limpia:

# En conftest.py o al inicio del módulo
from main import items_db, next_id
# Mejor: refactorizar la app para inyectar el storage (patrón dependency injection)

En la app de ejemplo, items_db y next_id son globales. Para tests aislados, tendrías que limpiar items_db y resetear next_id en un fixture autouse=True, o usar una app que acepte un storage inyectado. Para aprendizaje, ejecutar tests en orden aleatorio (pytest --random-order) ayuda a detectar dependencias ocultas.


Problema 3: 422 en POST cuando crees que el payload es correcto

Causa: Pydantic espera tipos específicos. price debe ser float, no string. Faltan campos requeridos.

Solución: Usa json= con tipos Python correctos:

# ❌ MAL
client.post("/items", json={"name": "X", "price": "10"})  # price es str

# ✅ BIEN
client.post("/items", json={"name": "X", "price": 10.0})

Revisa el schema del modelo (ItemCreate) y el detalle del 422 en response.json() — FastAPI devuelve los errores de validación.


Problema 4: TestClient no encuentra la app o hay ImportError

Causa: Import circular o la app no se importa correctamente.

Solución: Importa la app después de que esté configurada. Si main.py tiene código que se ejecuta al importar (ej. conexión a DB), considera crear la app en una función o retrasar la creación del TestClient:

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient

# Importar app después de que el entorno esté listo
from main import app

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

Problema 5: Endpoint async y TestClient — ¿funciona?

Causa: Dudas sobre si TestClient soporta endpoints async.

Solución: Sí. TestClient usa httpx y ejecuta la app async internamente. No necesitas configurar nada especial. Los endpoints async def funcionan igual que def con TestClient.


Ejercicios

Ejercicio 1: Test básico de GET (Fácil)

Escribe un test que verifique que GET /items retorna status 200 y que el body es una lista (puede estar vacía).

Ver solución
def test_get_items_returns_200_and_list():
    response = client.get("/items")
    assert response.status_code == 200
    assert isinstance(response.json(), list)

Ejercicio 2: Test de POST con validación (Fácil)

Escribe dos tests: uno que verifica que crear un item con payload válido retorna 201 y contiene id, name y price; otro que verifica que enviar price negativo retorna 422.

Ver solución
def test_create_item_valid_returns_201_with_id():
    payload = {"name": "Valid item", "price": 19.99}
    response = client.post("/items", json=payload)
    assert response.status_code == 201
    data = response.json()
    assert "id" in data
    assert data["name"] == "Valid item"
    assert data["price"] == 19.99


def test_create_item_negative_price_returns_422():
    payload = {"name": "Item", "price": -5.0}
    response = client.post("/items", json=payload)
    assert response.status_code == 422

Ejercicio 3: Flujo CREATE → GET (Medio)

Escribe un test que cree un item con POST, obtenga el ID de la response, y luego haga GET de ese item verificando que los datos coinciden.

Ver solución
def test_create_then_get_returns_same_data():
    # ARRANGE & ACT: crear
    payload = {"name": "Flow item", "price": 33.0}
    create_resp = client.post("/items", json=payload)
    assert create_resp.status_code == 201
    created = create_resp.json()
    item_id = created["id"]

    # ACT: obtener
    get_resp = client.get(f"/items/{item_id}")

    # ASSERT
    assert get_resp.status_code == 200
    retrieved = get_resp.json()
    assert retrieved["id"] == item_id
    assert retrieved["name"] == payload["name"]
    assert retrieved["price"] == payload["price"]

Ejercicio 4: Fixture created_item (Medio)

Crea una fixture created_item que haga POST de un item y retorne el JSON. Usa esa fixture en dos tests: uno que hace GET del item y otro que hace DELETE y verifica 204.

Ver solución
@pytest.fixture
def client():
    return TestClient(app)


@pytest.fixture
def created_item(client):
    response = client.post("/items", json={"name": "Fixture item", "price": 7.5})
    assert response.status_code == 201
    return response.json()


def test_get_created_item(client, created_item):
    item_id = created_item["id"]
    response = client.get(f"/items/{item_id}")
    assert response.status_code == 200
    assert response.json()["name"] == "Fixture item"


def test_delete_created_item(client, created_item):
    item_id = created_item["id"]
    response = client.delete(f"/items/{item_id}")
    assert response.status_code == 204

Ejercicio 5: Test del endpoint protegido (Medio)

Escribe tres tests para /admin/users: sin header Authorization (401), con token inválido (401), y con token válido Bearer secret-token-123 (200, con "users" en el body).

Ver solución
def test_admin_without_token_returns_401():
    response = client.get("/admin/users")
    assert response.status_code == 401


def test_admin_with_invalid_token_returns_401():
    response = client.get("/admin/users", headers={"Authorization": "Bearer bad-token"})
    assert response.status_code == 401


def test_admin_with_valid_token_returns_200():
    response = client.get(
        "/admin/users",
        headers={"Authorization": "Bearer secret-token-123"}
    )
    assert response.status_code == 200
    data = response.json()
    assert "users" in data

Ejercicio 6: Prompt para Claude Code (Medio)

Escribe un prompt que le pida a Claude Code generar integration tests para una API con endpoints GET /products y POST /products (crear producto con name, price). Incluye: uso de TestClient, fixtures para client, happy path y 422 para payload inválido.

Ver solución
Genera integration tests para una API FastAPI con estos endpoints:

- GET /products: retorna lista de productos
- POST /products: crea producto con body {"name": str, "price": float}

Requisitos:
1. Usa FastAPI TestClient
2. Fixture `client` que retorna TestClient(app)
3. Para GET: test que retorna 200 y lista (vacía o con datos)
4. Para POST: test 201 con payload válido; test 422 con name vacío; test 422 con price negativo
5. Usa arrange-act-assert
6. Archivo: tests/integration/test_products_api.py

Resumen

  • ✅ Integration testing para APIs verifica endpoints HTTP como un cliente real: status codes, body, headers.
  • ✅ FastAPI TestClient envuelve la app, envía requests en proceso (sin servidor) y retorna responses verificables.
  • ✅ Tests de GET: 200, estructura del body, 404 para recurso inexistente.
  • ✅ Tests de POST: 201 con payload válido, 422 con validación fallida (campos faltantes, tipos incorrectos).
  • ✅ Tests de PUT y DELETE: 200/204 en éxito, 404 cuando el recurso no existe.
  • ✅ Endpoints protegidos: 401 sin token o con token inválido, 200 con token válido en el header Authorization.
  • ✅ Fixtures client y created_item evitan repetición y preparan datos para tests.
  • ✅ Claude Code puede generar integration tests con prompts que especifiquen endpoints, cobertura y uso de fixtures.
  • ✅ La próxima cápsula cubre database testing con fixtures — setup y teardown de test DB.

Próxima cápsula: Database testing con fixtures — configurar una base de datos de pruebas, seed data, y aislamiento entre tests.


Recursos Adicionales

  1. FastAPI: Testing - Documentación oficial de testing con TestClient
  2. Starlette TestClient - Implementación base del TestClient
  3. httpx Documentation - Cliente HTTP que usa TestClient internamente
  4. pytest Fixtures - Fixtures para client y datos
  5. Test Pyramid (Martin Fowler) - Estrategia de niveles de testing

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