Un endpoint de FastAPI puede responder 202 Accepted y, aun así, dejar al test esperando un correo que llega unos segundos después. En ese momento es tentador añadir otro sleep(5), pero el problema normalmente no es la espera: es que el test no sabe distinguir su mensaje de los demás.
En proyectos con registro, invitaciones o recuperación de cuenta, un buzón de prueba debe comportarse como una dependencia con contrato. El adaptador puede ser pequeño, pero tiene que responder tres preguntas: qué ejecución creó el mensaje, en que momento se puede aceptar y qué evidencia queda si no aparece.
El problema no es esperar, es identificar
Buscar el último email parece una solución rápida. Sin embargo, falla cuando dos tests corren en paralelo, cuando un reintento reutiliza una dirección o cuando la entrega tarda más de lo normal. Los reintentos se vuelve confuso y el fallo termina diciendo solamente timeout.
Un contrato más seguro asigna una identidad a cada ejecución y conserva un run_id. Ese valor puede viajar en el destinatario de prueba, en el asunto o en un dato controlado del cuerpo. No hace falta exponerlo a usuarios reales; solo debe permitir que el test relacione una petición con su mensaje.
También conviene guardar el instante en que se dispara la acción. La fecha por sí sola no identifica un correo, pero ayuda a descartar mensajes anteriores y hace el diagnóstico mas fácil.
Qué debe hacer el adaptador
La aplicación no debería conocer los detalles de la API del buzón. Una interfaz pequeña mantiene el test legible y permite sustituir el proveedor por un fake durante el desarrollo:
from dataclasses import dataclass from datetime import datetime @dataclass class MailboxMessage: message_id: str subject: str text: str received_at: datetime class Mailbox: async def create(self) -> str: """Devuelve un identificador de buzón aislado.""" raise NotImplementedError async def list_messages(self, inbox_id: str) -> list[MailboxMessage]: raise NotImplementedError async def close(self, inbox_id: str) -> None: raise NotImplementedError El test solo necesita crear, leer y cerrar. Esa frontera es útil para una free temp email o cualquier servicio temporal que uses en un entorno controlado. La implementación concreta puede traducir respuestas HTTP, paginación y autenticación sin contaminar los casos de prueba.
Polling con evidencia y límites
El polling debe tener un límite claro y registrar por qué descartó cada mensaje. Una espera fija es simple, pero no explica si el mensaje nunca fue enviado o si llegó después del límite:
import asyncio from datetime import datetime, timezone async def wait_for_confirmation(mailbox, inbox_id, run_id, started_at, timeout=30): deadline = asyncio.get_running_loop().time() + timeout seen_ids = [] while asyncio.get_running_loop().time() < deadline: messages = await mailbox.list_messages(inbox_id) for message in messages: if message.message_id not in seen_ids: seen_ids.append(message.message_id) if message.received_at <= started_at: 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_ids}" ) El filtro importa más que el intervalo. Primero se descartan mensajes anteriores, luego se busca la identidad de la ejecución y finalmente se valida el asunto. Si la API devuelve varias páginas, el adaptador debe deduplicar por message_id; de lo contrario, la misma notificación puede parecer una señal utiles para dos comprobaciones.
En un test real, started_at debería crearse justo antes de llamar al endpoint. Usa siempre una zona horaria explícita, como UTC, y evita comparar timestamps ingenuos. Ese detalle ahorra un fallo raro cuando el portátil y CI tienen configuraciones distintas.
Aislamiento y limpieza en CI
Cada intento debería tener una política visible: buzón nuevo, buzón reutilizado con limpieza previa o un fake local. Mezclar las tres opciones sin documentarlo hace que un tempail de una ejecución anterior parezca evidencia nueva; incluso una búsqueda con el texto tamp mail com puede llevar a diagnósticos equivocados.
La limpieza debe ejecutarse en finally, tanto si la aserción pasa como si falla:
inbox_id = await mailbox.create() try: started_at = datetime.now(timezone.utc) await client.post("/signup", json={"email": test_address}) message = await wait_for_confirmation( mailbox, inbox_id, run_id, started_at ) assert test_address in message.text finally: await mailbox.close(inbox_id) En CI, conserva run_id, inbox_id, started_at, los ids observados y el motivo final. No guardes tokens ni el contenido completo del correo en los logs. Es mejor una evidencia pequeña y estable que un volcado enorme que nadie revisa.
Si el flujo también tiene frontend, los estados visibles deben explicar si está esperando, expiró o fue confirmado. Este enfoque combina bien con estados claros para un inbox temporal. Y cuando el email forma parte de onboarding, conviene probar la activación con señales de email sin confundir entrega con activación.
Checklist para el siguiente test
- Crea o reserva el buzón antes de disparar el endpoint.
- Genera un
run_idúnico por caso y por reintento. - Registra
started_atcon una zona horaria explícita. - Filtra por fecha, destinatario, asunto y
run_id. - Deduplica mensajes por un identificador estable.
- Define un timeout y conserva los ids que observaste.
- Cierra el buzón en éxito y en error.
- Evita imprimir tokens, direcciones reales o cuerpos completos.
Preguntas rápidas
¿Debo usar siempre un buzón temporal?
Para pruebas de integración que necesitan observar entrega, suele ser práctico. Para la mayoría de unit tests, un fake de Mailbox es mas rapido, barato y determinista. Reserva la entrega real para unos pocos escenarios de extremo a extremo.
¿Qué hago si el correo llega justo después del timeout?
Guarda la evidencia y revisa los límites del entorno antes de subirlos. Un timeout mayor no arregla un filtro incorrecto, una cola saturada o una identidad que se reutilizó.
¿El run_id debe ir en el cuerpo?
No necesariamente. Un destinatario aislado o un header de correlación puede bastar. Elige el lugar que puedas verificar sin guardar datos sensibles, y documenta qué parte del mensaje sera aceptada.
Un adaptador de buzón convierte una integración frágil en una dependencia observable. FastAPI sigue disparando el flujo, pero el test puede explicar si el email no salió, llegó tarde, pertenecía a otra ejecución o fue limpiado correctamente. Esa claridad vale mas que añadir otro reintento a ciegas.