Las pruebas de registro que dependen de un email suelen fallar de una forma dificil de explicar. El test crea un usuario, solicita un mensaje de verificación y espera una señal. Si la consulta tarda, el runner reintenta. Entonces aparecen dos usuarios, dos mensajes o un resultado que ya no sabemos a qué ejecución pertenece.
En mis scripts de backend, trato cada email de prueba como una operación idempotente. La idea es simple: repetir la misma petición debe devolver el mismo resultado lógico, aunque el proveedor tarde o la automatización pierda una respuesta. Esto hace que un endpoint para validar emails de prueba sin ruido sea mucho más util en CI.
El problema de repetir una prueba de email
Imagina este flujo:
crear usuario -> pedir verificación -> revisar buzón -> confirmar usuario
Un timeout entre “pedir verificación” y “revisar buzón” no significa que la primera operación no ocurrió. Sin un identificador estable, el test no puede distinguir entre una petición pendiente y una nueva petición. La reaccion habitual es repetir todo el flujo, y ahi nace el duplicado.
Hay dos claves para evitarlo:
- El cliente envía una clave de idempotencia por ejecución.
- El servidor guarda el estado y el resultado asociado a esa clave.
La clave no debe ser el email solamente. Un mismo buzón puede servir para varias ejecuciones y una prueba nueva necesita su propio contexto. Una combinación como run_id + caso_de_prueba suele ser suficiente para empezar.
Un contrato idempotente para el endpoint
El endpoint puede aceptar una petición pequeña y explicita:
{
"email": "qa-42@example.test",
"test_case": "signup_verification",
"idempotency_key": "run-2026-10-20-42"
}
Para la misma idempotency_key, la API debería devolver uno de estos resultados:
-
201 createdsi acaba de registrar la solicitud. -
200 existingsi la solicitud ya estaba completa. -
202 pendingsi sigue en proceso y todavía no hay resultado final. -
409 conflictsi la clave se reutilizó con un email o caso diferente.
El 409 es importante. Si una clave cambia de significado en mitad de una prueba, ocultar el problema detrás de un 200 produce diagnósticos confusos. Un mensaje de error claro ahorra bastante tiempo despues.
Implementación pequeña con FastAPI
Para una prueba local no hace falta construir un sistema enorme. Un repositorio en memoria permite probar el contrato antes de elegir PostgreSQL o Redis:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
app = FastAPI()
requests: dict[str, dict] = {}
class VerificationRequest(BaseModel):
email: EmailStr
test_case: str
idempotency_key: str
@app.post("/verification-requests")
def create_request(payload: VerificationRequest):
saved = requests.get(payload.idempotency_key)
if saved:
same_input = (
saved["email"] == str(payload.email)
and saved["test_case"] == payload.test_case
)
if not same_input:
raise HTTPException(status_code=409, detail="Clave reutilizada")
return {"status": "existing", **saved}
record = {
"email": str(payload.email),
"test_case": payload.test_case,
"status": "pending",
}
requests[payload.idempotency_key] = record
return {"status": "created", **record}
En producción, la escritura debe tener una restricción única sobre idempotency_key. El patrón de “buscar y luego insertar” puede sufrir una carrera cuando dos workers reciben la misma petición al mismo tiempo. La base de datos tiene que ser la ultima defensa, no sólo el código Python.
Cómo conectar la automatización sin duplicados
La automatización puede separar tres pasos: reservar la solicitud, esperar la señal y confirmar el resultado. Cada paso consulta el estado existente antes de hacer trabajo nuevo.
def wait_for_verification(client, key: str, timeout: float = 30):
request = client.get(f"/verification-requests/{key}")
if request["status"] == "confirmed":
return request
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
message = client.find_message(key=key)
if message:
return client.confirm(key=key, message_id=message["id"])
time.sleep(1)
return {"status": "pending", "key": key}
El worker no deberia crear otra solicitud al recibir pending. Puede guardar el último momento de consulta y devolver el control al runner. En un sistema que necesita un correo temporal desechable para cada caso, conviene usar formularios async que respetan el foco en la interfaz de pruebas, pero la misma regla aplica: una ejecución, una clave, un estado observable.
Si el equipo necesita generar un correo desechable, esa acción debe quedar fuera de la clave de idempotencia o asociarse a ella de forma explícita. De lo contrario, un reintento puede reservar otro buzón y perder el mensaje original. En mis notas he visto nombres como temp mailid y temp org mail; aunque parezcan equivalentes, conviene conservar el identificador que realmente devolvió el adaptador.
También es útil registrar created_at, expires_at, message_id y un código de error del proveedor. No guardaría el contenido completo del mensaje si sólo se necesita verificar un token. Menos datos hacen el debugging mas seguro y reducen el ruido en los logs.
Preguntas rápidas
¿Una clave de idempotencia debe durar para siempre?
No. Debe durar lo suficiente para cubrir los reintentos de la ejecución. Un TTL evita que el almacén crezca sin limite y permite reutilizar nombres de pruebas antiguas con un nuevo run_id.
¿Qué hacemos si el mensaje llega después del timeout?
Marca el caso como pending o expired, pero conserva la evidencia. Un proceso de limpieza puede retirar el buzón y el runner puede decidir si reintenta toda la prueba. No conviertas un timeout en “email inexistente” sin comprobar el estado del proveedor.
¿Esto sustituye una prueba end-to-end?
No. Este patrón mejora la confiabilidad del flujo y hace sus fallos más legibles. Todavía conviene tener algunas pruebas end-to-end con un proveedor real y otras unitarias con un adaptador falso.
Checklist antes de llevarlo a CI
- [ ] Cada ejecución genera un
run_idúnico. - [ ] La clave de idempotencia combina ejecución y caso de prueba.
- [ ] Reutilizar la clave con datos distintos devuelve
409. - [ ] La base de datos protege la inserción concurrente.
- [ ]
pending,expiredyprovider_errorson estados distintos. - [ ] El token o contenido sensible no aparece en logs.
- [ ] El cleanup conoce la caducidad del buzón y de la solicitud.
No hace falta añadir mucha infraestructura para obtener el beneficio. Con un contrato pequeño, estados claros y reintentos que consultan antes de crear, las pruebas de email dejan de depender de la suerte. La automatización se vuelve mas predecible y el diagnóstico, bastante mas rapido.
Top comments (0)