Algunas pruebas fallan de forma intermitente porque dependen de tiempos, procesos asincrónicos o recursos que no están listos inmediatamente. La solución no siempre es agregar una espera o un reintento.
En este tema veremos cuándo usar esperas y reintentos, cuándo evitarlos y cómo diseñarlos para que no oculten errores reales.
Una prueba frágil es una prueba que a veces pasa y a veces falla sin que el código haya cambiado. También se la suele llamar prueba intermitente.
Causas frecuentes:
Agregar reintentos sin entender el problema puede hacer que la prueba parezca estable, pero el defecto sigue allí. Una prueba que solo pasa después de varios intentos está dando una señal que debemos investigar.
Los reintentos pueden ser útiles, pero deben usarse como herramienta controlada, no como forma de ignorar fallas.
Una espera fija detiene la prueba durante una cantidad de segundos:
import time
def test_ejemplo_con_espera_fija():
time.sleep(2)
assert True
El problema es que puede ser demasiado corta en una computadora lenta y demasiado larga en una computadora rápida.
En lugar de esperar siempre dos segundos, podemos esperar hasta que una condición se cumpla o hasta que se agote un timeout.
Crea app/esperas.py:
import time
def esperar_hasta(condicion, timeout=5, intervalo=0.1):
inicio = time.monotonic()
while time.monotonic() - inicio < timeout:
if condicion():
return True
time.sleep(intervalo)
return False
Esta función usa polling: revisa una condición varias veces hasta que se cumpla.
Para no hacer lenta la prueba, podemos usar una condición que ya sea verdadera:
from app.esperas import esperar_hasta
def test_esperar_hasta_con_condicion_verdadera_devuelve_true():
assert esperar_hasta(lambda: True, timeout=1, intervalo=0.01) is True
La función retorna enseguida porque la condición se cumple desde el inicio.
Podemos probar el caso de timeout usando valores muy pequeños:
from app.esperas import esperar_hasta
def test_esperar_hasta_con_condicion_falsa_devuelve_false():
resultado = esperar_hasta(lambda: False, timeout=0.01, intervalo=0.001)
assert resultado is False
La prueba sigue siendo rápida y verifica el comportamiento de timeout.
Crea en app/esperas.py:
def reintentar(funcion, intentos=3):
ultimo_error = None
for _ in range(intentos):
try:
return funcion()
except Exception as error:
ultimo_error = error
raise ultimo_error
Esta función intenta ejecutar una operación varias veces. Si todas fallan, relanza el último error.
Podemos simular una operación que falla una vez y luego pasa:
from app.esperas import reintentar
def test_reintentar_devuelve_resultado_si_un_intento_pasa():
estado = {"intentos": 0}
def operacion():
estado["intentos"] += 1
if estado["intentos"] < 2:
raise RuntimeError("Falla temporal")
return "ok"
assert reintentar(operacion, intentos=3) == "ok"
assert estado["intentos"] == 2
También debemos probar el caso en que todos los intentos fallan:
import pytest
from app.esperas import reintentar
def test_reintentar_si_todos_los_intentos_fallan_relanzara_error():
def operacion():
raise RuntimeError("Falla permanente")
with pytest.raises(RuntimeError, match="Falla permanente"):
reintentar(operacion, intentos=3)
No todos los errores deberían reintentarse. Por ejemplo, un error de validación probablemente no se arregle repitiendo la misma operación.
Podemos limitar los errores reintentables:
def reintentar(funcion, intentos=3, errores_reintentables=(TimeoutError,)):
ultimo_error = None
for _ in range(intentos):
try:
return funcion()
except errores_reintentables as error:
ultimo_error = error
raise ultimo_error
Así evitamos ocultar errores de programación o validación.
Prueba que un error no reintentable falle de inmediato:
import pytest
def test_reintentar_no_reintenta_value_error():
estado = {"intentos": 0}
def operacion():
estado["intentos"] += 1
raise ValueError("Dato inválido")
with pytest.raises(ValueError, match="Dato inválido"):
reintentar(operacion, intentos=3, errores_reintentables=(TimeoutError,))
assert estado["intentos"] == 1
Las esperas y reintentos tienen sentido cuando hay una operación que realmente puede tardar o fallar temporalmente:
No tienen sentido para corregir pruebas mal aisladas o datos compartidos.
Podemos esperar a que exista un archivo:
from app.esperas import esperar_hasta
def test_esperar_hasta_que_exista_archivo(tmp_path):
ruta = tmp_path / "resultado.txt"
ruta.write_text("ok", encoding="utf-8")
assert esperar_hasta(ruta.exists, timeout=1, intervalo=0.01) is True
En este ejemplo el archivo ya existe, pero el patrón sirve para procesos que lo generan después.
Si una prueba realmente necesita esperas o procesos lentos, puede marcarse como lento:
import pytest
@pytest.mark.lento
def test_proceso_con_espera_real():
assert True
Luego puedes excluirla durante una ejecución rápida:
python -m pytest -m "not lento"
Revisa una prueba si ves alguna de estas señales:
sleep constantemente.Antes de agregar reintentos, revisa:
Solo después de revisar esas causas tiene sentido considerar esperas o reintentos.
Las pruebas de lógica Python deberían ser rápidas. Si una prueba unitaria necesita dormir varios segundos, probablemente hay un diseño que se puede mejorar.
Una alternativa es inyectar funciones de tiempo o reemplazar dependencias con monkeypatch.
Si una prueba o helper usa reintentos, documenta la razón. Por ejemplo:
# Se reintenta porque el archivo puede aparecer unos milisegundos después
# de que termina el proceso que lo genera.
assert esperar_hasta(ruta.exists, timeout=2, intervalo=0.05)
Un comentario breve puede evitar que el reintento parezca un parche sin explicación.
Crea una función esperar_archivo en app/esperas.py. Debe recibir una ruta, un timeout y un intervalo. Debe devolver True si el archivo existe antes del timeout y False si no aparece.
Luego crea pruebas para:
Función:
def esperar_archivo(ruta, timeout=5, intervalo=0.1):
return esperar_hasta(ruta.exists, timeout=timeout, intervalo=intervalo)
Pruebas:
from app.esperas import esperar_archivo
def test_esperar_archivo_con_archivo_existente_devuelve_true(tmp_path):
ruta = tmp_path / "resultado.txt"
ruta.write_text("ok", encoding="utf-8")
assert esperar_archivo(ruta, timeout=1, intervalo=0.01) is True
def test_esperar_archivo_con_archivo_inexistente_devuelve_false(tmp_path):
ruta = tmp_path / "no_existe.txt"
assert esperar_archivo(ruta, timeout=0.01, intervalo=0.001) is False
Ejecuta:
python -m pytest tests/test_esperas.py
Antes de continuar con el próximo tema, verifica lo siguiente:
time.sleep innecesario.lento.python -m pytest.En este tema vimos cómo tratar reintentos, esperas y pruebas frágiles. Una espera bien diseñada puede ser útil, pero una espera agregada sin criterio puede ocultar problemas reales y volver lenta la suite.
En el próximo tema trabajaremos con ejecución paralela de pruebas usando pytest-xdist.