DEV Community

Olivia Cheng
Olivia Cheng

Posted on

FastAPI: contratos claros para emails de prueba

Cuando un test solicita un email de verificación, muchas aplicaciones consideran que el trabajo terminó al recibir un 202 Accepted. Ese resultado solo dice que la API aceptó la petición. No confirma que el mensaje se haya generado, entregado al buzón de prueba o que el contenido sea el esperado.

En proyectos Python he encontrado que el problema no suele ser enviar el email, sino definir qué significa que el flujo terminó bien. Un contrato pequeño en FastAPI puede separar esas etapas y hacer que un fallo sea mucho mas facil de investigar. Tambien evita que un test pase por accidente porque leyó un mensaje viejo.

El problema: aceptar no es verificar

Imagina un registro de usuario con este flujo:

  1. El test llama a POST /users.
  2. La aplicación publica un evento de verificación.
  3. Un worker construye y envía el email.
  4. El buzón de prueba lo hace visible.
  5. El test lee el enlace y confirma la cuenta.

Cada paso puede tener una latencia diferente. Si el endpoint responde de inmediato y el test busca el mensaje una sola vez, aparecerán fallos intermitentes. Si el buzón se reutiliza sin un identificador único, el test puede encontrar el email de otra ejecución y mostrar un falso positivo.

Para el frontend, también es útil que cada estado sea explicable; este artículo sobre estados accesibles para email de prueba muestra por qué “esperando” y “falló” no deberían verse como el mismo resultado.

Define el contrato del flujo

Antes de escribir el test, define una respuesta y una evidencia mínima. Por ejemplo, POST /test-email puede devolver:

{
  "run_id": "ci-2026-1042",
  "mailbox": "ci-2026-1042@example.test",
  "status": "queued"
}
Enter fullscreen mode Exit fullscreen mode

El run_id debe viajar en el evento y en el asunto o cuerpo del mensaje. El endpoint de lectura puede ser GET /test-email/{run_id}, con estados como queued, delivered, mismatch y expired. Así el consumidor no necesita adivinar si “no encontrado” significa que el worker sigue trabajando o que el mensaje nunca llegará.

Este contrato tambien debe especificar una ventana de espera, qué campos se comparan y qué datos se guardan. Para depurar normalmente alcanza con el identificador del mensaje, timestamps, asunto, destinatario de prueba y resultado de cada intento. Guardar todo el contenido durante meses es una decisión de privacidad que no deberia ser automática.

Un endpoint FastAPI pequeño y observable

La API no necesita hacer polling dentro de la petición que crea el usuario. Puede encolar el trabajo y exponer una consulta separada:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class EmailRun(BaseModel):
    run_id: str
    mailbox: str
    status: str

@app.post("/test-email", response_model=EmailRun, status_code=202)
async def queue_test_email(run_id: str, mailbox: str):
    # La implementación real publica un evento idempotente.
    return EmailRun(run_id=run_id, mailbox=mailbox, status="queued")

@app.get("/test-email/{run_id}", response_model=EmailRun)
async def read_test_email(run_id: str):
    result = await lookup_email_run(run_id)
    if result is None:
        raise HTTPException(status_code=404, detail="run not found")
    return result
Enter fullscreen mode Exit fullscreen mode

El ejemplo es intencionalmente pequeño: el valor está en el límite entre estados, no en esconder toda la lógica en el endpoint. En producción, lookup_email_run debería leer de una fuente consistente y no depender de una variable global mutable.

La respuesta de estado también ayuda a medir activación y abandono. Si el producto tiene varios pasos de onboarding, conviene revisar cohortes de activación por email con el mismo identificador de ejecución, en vez de mezclar eventos de usuarios reales y tests.

Polling, idempotencia y limpieza

El cliente puede consultar cada pocos segundos con un límite total. Un backoff de 2, 4, 8 y 16 segundos suele ser suficiente para un smoke test; no hace falta bombardear el buzón. Al terminar el plazo, devuelve un error con la última evidencia, no solo un timeout genérico.

La creación del evento debe ser idempotente. Si CI reintenta la petición por un corte de red, el mismo run_id no debería crear dos usuarios ni dos procesos de verificación. El servidor puede conservar una clave de idempotencia corta y responder con el estado existente.

También hay que separar “mensaje no visible todavía” de “mensaje incorrecto”. Un correo que aparece con otro run_id suele indicar aislamiento roto; un asunto correcto pero sin enlace apunta a una plantilla; un buzón sin respuesta puede ser un problema de proveedor o autenticación. Esa distinción ahorra bastante tiempo en una guardia.

Por último, limpia los mensajes y buzones al finalizar. Si la limpieza falla, registra el incidente sin ocultar el resultado principal del test. Para nombres de búsqueda que aparecen en documentación informal, alguien puede escribir “tempail” o “tem email”; el código y los contratos deben usar siempre la misma nomenclatura correcta.

Preguntas frecuentes

¿Conviene esperar el email dentro de POST /users?

No para la mayoría de tests. Mantener la petición abierta mezcla creación de usuario con entrega de correo y hace mas dificil distinguir dónde falló el flujo. Un 202 más una consulta de estado representa mejor el trabajo asíncrono.

¿Debo usar un buzón diferente por cada ejecución?

Es lo mas sencillo. Si no es posible, añade un run_id único, filtra por él y aplica una limpieza confiable. Reutilizar sin esos controles convierte un test rápido en una fuente de resultados dudosos.

¿Qué evidencia debería conservar CI?

El identificador de ejecución, estado final, tiempos de cola y entrega, asunto, identificador del mensaje y causa de fallo. El cuerpo completo solo debe conservarse cuando sea necesario para depurar y con una retención corta.

Checklist para el test

  • [ ] La API devuelve un run_id estable y un estado explícito.
  • [ ] El evento y el mensaje incluyen el mismo identificador.
  • [ ] La lectura distingue queued, delivered, mismatch y expired.
  • [ ] Los reintentos no duplican la operación de negocio.
  • [ ] El polling tiene backoff y un límite total.
  • [ ] CI guarda evidencia mínima y útil.
  • [ ] Los mensajes de prueba se limpian después de cada ejecución.

Un contrato claro no hace que el email sea instantáneo, pero sí convierte una espera ambigua en una señal que el equipo puede entender. Con FastAPI, unos pocos estados bien definidos suelen dar más confianza que una cadena de reintentos sin contexto.

Top comments (0)