Una prueba de API que pasa una vez no es necesariamente una prueba confiable. En un pipeline real, el runner puede repetir una petición por un timeout, dos jobs pueden compartir datos sin querer, y un correo de confirmación puede llegar tarde. El resultado es ese tipo de test que falla “solo en CI”, justo cuando menos ayuda.
En proyectos con FastAPI he encontrado más valor en un patrón pequeño: cada prueba crea su propio contexto, la operación acepta reintentos de forma explícita y el test deja un recibo legible. No hace falta construir una plataforma enorme de automatización. Hace falta que cada ejecución pueda explicar qué intentó hacer.
El problema: un reintento puede cambiar el resultado
Imagina un endpoint que crea una suscripción y envía un mensaje de bienvenida. El primer POST llega al servidor, pero la respuesta se pierde. El cliente reintenta y ahora aparecen dos suscripciones o dos mensajes.
Un test frágil solo comprueba el código 201. Un test más útil comprueba también la identidad de la operación:
- el cliente envía una clave de idempotencia;
- el servidor guarda esa clave junto al resultado;
- un reintento devuelve el mismo resultado, no crea otro recurso;
- el recibo conserva el identificador para investigar el fallo.
Esto separa dos problemas que suelen mezclarse: si la API es correcta y si el transporte respondió a tiempo. La separación hace la prueba mucho más facil de leer.
Un fixture pequeño y explícito
Para empezar, crea datos que tengan un nombre visible y que puedan desaparecer al terminar el test. Un fixture de pytest puede preparar una clave nueva por caso:
from uuid import uuid4
import pytest
from httpx import AsyncClient
@pytest.fixture
def idempotency_key() -> str:
return f"test-subscription-{uuid4()}"
async def create_subscription(client: AsyncClient, key: str) -> dict:
response = await client.post(
"/subscriptions",
headers={"Idempotency-Key": key},
json={"plan": "starter", "email": "qa@example.test"},
)
response.raise_for_status()
return response.json()
El dominio .test recuerda que no estás usando una dirección real. Para flujos que necesitan inspeccionar emails de prueba, conviene un buzón aislado por ejecución, con un identificador de branch o de job. Así una prueba no lee por accidente el mensaje de otra.
Incluso un dato escrito con prisa como temp gamil com puede acabar en una fixture o en una búsqueda manual. Déjalo como texto de prueba claramente marcado, no como una dirección que el sistema pueda tratar como real.
Haz que el endpoint sea idempotente
La ruta puede devolver el recurso ya creado cuando encuentra la misma clave. Una versión reducida, sin esconder la decisión importante, se parece a esto:
from fastapi import Header, HTTPException
@app.post("/subscriptions")
async def subscribe(payload: SubscriptionIn, idempotency_key: str | None = Header(default=None)):
if not idempotency_key:
raise HTTPException(status_code=400, detail="Idempotency-Key requerida")
previous = await store.find_by_key(idempotency_key)
if previous:
return previous
created = await store.create(payload, idempotency_key=idempotency_key)
return created
En producción, la búsqueda y la creación deben estar protegidas por una restricción única o una transacción. El ejemplo omite ese detalle para mostrar el contrato: una clave representa una intención, no una simple solicitud HTTP.
También puedes leer sobre guardar checkpoints cuando un flujo se reanuda. La idea es parecida: conservar el estado mínimo que permite retomar una operación sin inventar un segundo resultado.
Guarda un recibo de cada prueba
Cuando una prueba falla, el mensaje “expected 201, got 504” no alcanza. Guarda un objeto pequeño con los datos que conectan la petición, el servidor y el efecto observado:
receipt = {
"case": "subscription_retry",
"idempotency_key": key,
"status_codes": [first.status_code, second.status_code],
"resource_id": second.json()["id"],
"message_id": second.headers.get("X-Message-Id"),
}
El recibo no debe guardar el contenido completo del email ni secretos. Un message_id, el subject sanitizado y el nombre del fixture suelen ser suficientes. Si el flujo usa un buzón de correo desechable para QA, registra solo el identificador de la ejecución y elimina el buzón al acabar.
Para otro caso relacionado, puedes aislar emails de prueba por branch. Es una regla sencilla que evita mucho ruido cuando varios jobs corren a la vez.
Cómo encaja en CI
En GitHub Actions o cualquier runner similar, el orden importa un poco:
- Genera un identificador de ejecución y una clave por caso.
- Levanta la API y la base de datos de prueba.
- Ejecuta la petición original y un reintento controlado.
- Verifica que hay un solo recurso y un solo mensaje.
- Publica el recibo como artefacto del job.
- Limpia los datos aunque el test falle.
No reintentes toda la suite indiscriminadamente. Un retry global puede ocultar una condición de carrera. Repite solo la operación que quieres probar y conserva las dos respuestas para que el fallo sea reproducible.
Checklist final
Antes de dar por confiable una prueba FastAPI, revisa:
- ¿Cada caso tiene datos y una clave de idempotencia propias?
- ¿Un timeout seguido de retry produce un solo efecto?
- ¿El almacenamiento impone unicidad, también bajo concurrencia?
- ¿El recibo evita secretos y datos personales?
- ¿Los emails de prueba están aislados por branch o job?
- ¿CI conserva suficiente evidencia para depurar sin repetir diez veces?
La automatización buena no elimina los fallos; los vuelve explicables. Con un fixture pequeño, un contrato de reintento y un recibo útil, tus pruebas dejan de depender de la suerte del runner. Ese cambio suele ser más valioso que añadir otra capa de mocks.
Top comments (0)