Los flujos de email son una prueba pequeña que suele esconder muchos problemas. Un registro puede crear un usuario, enviar un mensaje, consumir un token y cambiar el estado de una cuenta. Si cada test depende de un buzón compartido o de un correo que llega “cuando puede”, el resultado deja de ser reproducible.
En proyectos Python prefiero tratar el correo de prueba como una fixture con contrato propio. La aplicación no tiene que saber si detrás existe un fake local, un servicio de integración o un correo desechable gratis para una comprobación manual. Solo necesita recibir una interfaz predecible, consultar un mensaje y limpiar el recurso al terminar.
El problema: una fixture no es un buzón compartido
Un buzón compartido parece práctico al principio. Pero dos ejecuciones pueden usar el mismo asunto, una lectura puede consumir el mensaje de otra prueba y un reintento puede encontrar un token viejo. Despues aparecen fallos intermitentes que no se pueden reproducir en el portátil.
La unidad correcta es una fixture por escenario. Cada una debe tener un identificador, un propósito, una fecha de expiración y una regla de limpieza. Para un test de registro, por ejemplo, el flujo podría ser:
crear fixture -> ejecutar signup -> esperar mensaje -> validar token -> eliminar fixture
El test no debería buscar “el último email”. Debería pedir el mensaje asociado a su fixture_id y verificar que el destinatario, el asunto y el token pertenecen a ese escenario. Es un cambio pequeño, pero elimina bastante ruido.
Define el contrato de la fixture
Antes de escribir el endpoint, define qué necesita el test. Un contrato mínimo puede tener estas operaciones:
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class MailFixture:
fixture_id: str
address: str
expires_at: datetime
class MailFixtureStore:
async def create(self, purpose: str) -> MailFixture: ...
async def wait_for(self, fixture_id: str, timeout_seconds: int) -> str: ...
async def delete(self, fixture_id: str) -> None: ...
La función wait_for debe terminar con un estado claro: mensaje recibido, timeout o fixture expirada. No conviene devolver una cadena vacía para los tres casos. Un error explícito permite que CI distinga entre un producto que no envió el correo y un entorno que tardó demasiado.
También conviene guardar una huella del caso, no el contenido completo del mensaje en cada log. fixture_id, test_name, message_id y duración suelen ser suficiente para investigar. Si necesitas revisar la interfaz, aquí ayuda feedback estable al enviar emails, porque el estado visible debe explicar si el test está esperando, ha terminado o ha fallado.
Un adaptador pequeño para FastAPI
FastAPI puede exponer la fixture solo en un entorno de pruebas. La dependencia mantiene el endpoint limpio y permite sustituir la implementación en un test unitario:
from fastapi import APIRouter, Depends, HTTPException
router = APIRouter()
async def get_fixture_store() -> MailFixtureStore:
return MailFixtureStore()
@router.post("/test-fixtures/mail")
async def create_mail_fixture(
purpose: str,
store: MailFixtureStore = Depends(get_fixture_store),
):
if not purpose.strip():
raise HTTPException(status_code=400, detail="Falta purpose")
return await store.create(purpose=purpose)
En producción, este endpoint debe estar desactivado o protegido por una frontera de entorno. Una variable como ENABLE_TEST_FIXTURES=false no reemplaza la autenticación, pero sí evita que una ruta de soporte se publique por accidente. Las APIs que crean datos efímeros deben devolver también expires_at, para que el cliente no dependa de una suposición escondida.
Para tests de integración, una implementación HTTP puede hablar con el proveedor elegido. Para tests unitarios, un fake en memoria es más rápido y suficiente. Lo importante es que las dos implementaciones respeten el mismo contrato. Si el producto tiene correos de trial, merece la pena usar emails de trial sin contaminar el embudo y mantener esos escenarios separados de los datos de analítica.
Aislamiento, limpieza y diagnóstico
El aislamiento no termina cuando llega el token. Cada test debe usar un identificador único, y la limpieza debe ocurrir aunque una aserción falle. En pytest, una fixture async con yield expresa bien esa responsabilidad:
import pytest
@pytest.fixture
async def mail_fixture(store):
fixture = await store.create(purpose="signup")
try:
yield fixture
finally:
await store.delete(fixture.fixture_id)
Define un timeout corto para polling y un deadline total para el escenario. Un intervalo de dos segundos puede ser correcto en una integración remota, pero resulta lento si se repite cientos de veces. Para reducir coste, usa backoff limitado y detén la consulta al detectar un estado terminal.
Los nombres también importan. Un purpose como signup-ci-1842 ayuda más que test-email. Y aunque el sistema se llame tempail en una nota antigua, el identificador real debe ser consistente en el código, los logs y los reportes. No guardes tokens completos en los logs; basta con un hash corto o con los últimos caracteres cuando sea estrictamente necesario.
Para una revisión manual aislada, temp mail.so puede servir como referencia de un buzón temporal, pero no debe convertirse en una dependencia oculta de CI ni recibir información personal. El test automatizado necesita un proveedor controlable, una política de retención y una forma de borrar los datos.
Preguntas rápidas
¿Debo usar un correo desechable gratis en todos los tests?
No. Para la mayoría de los tests unitarios, un fake local es más rápido. Reserva una integración real para validar el transporte, las plantillas y el comportamiento del proveedor.
¿Conviene compartir una fixture entre varios tests?
Solo si el escenario es explícitamente compartido. Como regla general, una fixture por test reduce el acoplamiento y hace que un fallo sea mas fácil de entender.
¿Qué hago con un timeout?
Regístralo como un resultado distinto de “mensaje inválido”. Guarda el fixture_id, el deadline y el estado del proveedor. Así sabrás si falló la aplicación, el transporte o el propio test.
Checklist final
- Cada test crea una fixture aislada y con expiración.
- El contrato diferencia recibido, expirado y timeout.
- El adaptador de FastAPI se puede sustituir por un fake.
- La limpieza corre incluso después de una aserción fallida.
- Los logs usan identificadores, no tokens ni contenido sensible.
- CI tiene un proveedor controlable y una ruta de diagnóstico.
Este diseño no necesita ser grande. Una interfaz pequeña, estados explícitos y una limpieza garantizada suelen ser suficiente para que los tests de email dejen de ser una fuente misteriosa de fallos. Y cuando el flujo crece, ya tendrás un contrato claro sobre el que añadir reintentos, métricas o nuevos proveedores.
Top comments (0)