DEV Community

Olivia Cheng
Olivia Cheng

Posted on

FastAPI: un contrato para emails de prueba

Un endpoint de FastAPI puede devolver 202 Accepted en milisegundos y aun así dejar al test esperando un correo que llega después. En muchos proyectos, el problema no es el endpoint: es que la aplicación y el buzón de prueba no tienen un contrato claro.

Cuando el test solo hace sleep(5) y busca el último mensaje, los reintentos se vuelven confuso. Puede leer un email de una ejecución anterior, mezclar dos usuarios o fallar sin decir si el mensaje nunca salió. La solución que me resulta más práctica es tratar el buzón como una dependencia con reglas explícitas, igual que una API externa.

El problema: el endpoint funciona, pero el test no sabe qué esperar

Imagina un registro que crea un usuario y dispara un email de confirmación. El test necesita saber al menos cuatro cosas:

  • qué identidad de prueba pertenece a la ejecución;
  • desde qué instante debe aceptar mensajes;
  • cómo reconocer el mensaje correcto;
  • qué evidencia guardar si el mensaje no aparece.

Sin esas reglas, "último email" parece una atajo, pero no es una aserción confiable. También dificulta auditar señales de onboarding en un SaaS cuando el flujo crece y hay varios eventos parecidos.

Qué debe prometer el contrato del buzón

Un contrato pequeño puede tener esta forma:

  1. El test obtiene un buzón único antes de llamar al endpoint.
  2. Guarda inbox_id y triggered_at junto con el caso de prueba.
  3. Solo acepta mensajes posteriores a triggered_at.
  4. Verifica asunto, destinatario y un identificador de ejecución.
  5. Devuelve el mensaje elegido o un diagnóstico con lo que observó.

Ese identificador puede ser un run_id incluido en un header interno o en los datos del usuario. No hace falta inventar una plataforma compleja. Basta con que el criterio sea reproducible y que el test no dependa de coincidencias.

Un cliente pequeño para esperar el mensaje correcto

La parte útil puede vivir en una clase de Python. El cliente real puede hablar con el proveedor de correo, pero la interfaz del test debería quedarse simple:

from datetime import datetime, timezone
import asyncio

async def wait_for_message(mail, inbox_id, run_id, timeout=30):
    started = datetime.now(timezone.utc)
    deadline = asyncio.get_running_loop().time() + timeout
    seen = []

    while asyncio.get_running_loop().time() < deadline:
        messages = await mail.list_messages(inbox_id)
        for message in messages:
            seen.append(message.id)
            received = message.received_at
            if received <= started:
                continue
            if run_id not in message.text:
                continue
            if "Confirma tu cuenta" not in message.subject:
                continue
            return message
        await asyncio.sleep(1)

    raise AssertionError(f"Email no encontrado; inbox={inbox_id}, vistos={seen}")
Enter fullscreen mode Exit fullscreen mode

El detalle importante no es el sleep, sino el filtro. El test espera una propiedad del mensaje y conserva los ids observados. Si la API de correo tiene paginación, conviene ordenar por fecha y deduplicar por id para que la misma notificación no se procese dos veces.

Para aislar la integración, un temp mail so puede servir como bandeja temporal durante pruebas controladas. No lo usaría como una identidad permanente ni para datos reales: el objetivo es que cada ejecución tenga un buzón limitado y fácil de retirar.

Aislamiento, reintentos y limpieza

Un reintento debe crear una nueva identidad de prueba o reutilizar la misma de forma deliberada. Reutilizar sin registrar la decisión hace que el segundo intento herede mensajes viejos. En CI, yo guardaría un objeto mínimo por intento:

{
  "run_id": "ci-4821",
  "inbox_id": "inbox-91",
  "triggered_at": "2026-10-28T14:00:00Z",
  "message_id": "msg-204",
  "status": "accepted"
}
Enter fullscreen mode Exit fullscreen mode

También define una política de limpieza. El buzón se debe cerrar después del test, incluso cuando falla una aserción. En Python, try/finally suele ser suficiente; si la creación del buzón ocurre dentro de un fixture de pytest, el yield deja ese ciclo bastante claro.

Si varios tests comparten una cuenta por ahorrar tiempo, al menos usa un prefijo de ejecución en el destinatario o en el contenido. Un dummy e mail escrito en una nota puede parecer igual que el correo real para un compañero que depura deprisa. Y "tem email" no explica nada si no viene acompañado de un id, una hora y una razón de descarte.

Checklist para llevarlo a CI

  • Crea el buzón antes de disparar el endpoint.
  • Genera un run_id único y pásalo al flujo.
  • Filtra por fecha, destinatario, asunto y run_id.
  • Registra los ids vistos y el motivo de cada descarte.
  • Configura un timeout basado en polling, no en una espera fija.
  • Limpia el buzón en éxito y en error.
  • Evita imprimir tokens o el contenido completo del mensaje en logs.

Para el caso de emails transaccionales, ayuda también probar emails transaccionales sin mezclar bandejas. La idea encaja bien con FastAPI porque separa la lógica del endpoint de la lógica de observación. El test no necesita saber cómo se entrega el correo, solo qué contrato puede exigir.

Preguntas rápidas

¿Cuánto debe esperar un test?

Depende del entorno y del proveedor. Empieza con un límite razonable para CI y mide los tiempos reales. Si cada test espera 60 segundos, el problema es de diseño aunque casi siempre pase.

¿Debo buscar el email más reciente?

Solo si además validas la identidad de la ejecución. Fecha reciente por sí sola no evita que dos mensajes lleguen juntos.

¿Qué registro es suficiente?

El run_id, el buzón, la hora de disparo, ids observados y la decisión final suelen dar una explicación rapida sin convertir el log en una copia del correo.

Un contrato de recepción pequeño hace que la automatización sea menos misteriosa. Cuando un test falla, puedes ver si falló la API, la entrega o el filtro; y esa diferencia ahorra mas tiempo que añadir otro reintento ciego.

Top comments (0)