21. Reintentos, esperas y pruebas frágiles: cuándo usarlos y cuándo evitarlos

21.1 Objetivo del tema

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.

Objetivo práctico: reconocer pruebas frágiles y aplicar esperas o reintentos solo cuando estén justificados.

21.2 Qué es una prueba frágil

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:

  • Esperas fijas insuficientes.
  • Dependencia de tiempo real.
  • Servicios externos no controlados.
  • Datos compartidos entre pruebas.
  • Orden de ejecución asumido.
  • Condiciones de carrera.

21.3 Por qué no conviene ocultar fragilidad

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.

21.4 Espera fija con time.sleep

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.

21.5 Mejor alternativa: esperar una condición

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.

21.6 Probar una espera sin esperar realmente

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.

21.7 Probar timeout con valores pequeños

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.

21.8 Crear una función de reintento

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.

21.9 Probar reintento exitoso

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

21.10 Probar reintento agotado

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)

21.11 No reintentar cualquier error

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.

21.12 Probar errores no reintentables

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

21.13 Esperas y reintentos en automatización

Las esperas y reintentos tienen sentido cuando hay una operación que realmente puede tardar o fallar temporalmente:

  • Esperar a que se genere un archivo.
  • Esperar a que un proceso termine.
  • Reintentar una operación con posible timeout temporal.
  • Esperar a que una cola procese un mensaje.

No tienen sentido para corregir pruebas mal aisladas o datos compartidos.

21.14 Esperar un archivo temporal

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.

21.15 Marcar pruebas lentas

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"

21.16 Señales de una prueba frágil

Revisa una prueba si ves alguna de estas señales:

  • Falla solo algunas veces.
  • Pasa si se ejecuta sola, pero falla con toda la suite.
  • Necesita aumentar sleep constantemente.
  • Depende de una API o servicio externo.
  • Falla según el orden de ejecución.
  • Usa archivos o datos compartidos sin limpieza.

21.17 Estrategia para corregir fragilidad

Antes de agregar reintentos, revisa:

  1. Si la prueba prepara sus propios datos.
  2. Si depende de tiempo real.
  3. Si usa archivos compartidos.
  4. Si hay una dependencia externa sin controlar.
  5. Si el resultado esperado es realmente determinístico.

Solo después de revisar esas causas tiene sentido considerar esperas o reintentos.

21.18 Evitar esperas largas en pruebas unitarias

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.

21.19 Documentar reintentos

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.

21.20 Problemas frecuentes

  • La prueba sigue fallando aunque agregaste sleep: probablemente la causa no es solo tiempo.
  • La suite se volvió lenta: revisa esperas fijas innecesarias.
  • Los reintentos ocultan errores: limita los errores reintentables.
  • El timeout es demasiado alto: usa valores razonables y revisa el diseño.
  • La prueba pasa sola y falla con la suite: busca datos compartidos o dependencia de orden.

21.21 Ejercicio práctico

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:

  • Archivo que ya existe.
  • Archivo que no existe.
  • Uso de timeout pequeño para no hacer lenta la prueba.

21.22 Solución propuesta

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

21.23 Lista de verificación

Antes de continuar con el próximo tema, verifica lo siguiente:

  • Evitas time.sleep innecesario.
  • Prefieres esperar condiciones con timeout.
  • Los reintentos son limitados.
  • No reintentas errores que no son temporales.
  • Las pruebas lentas están marcadas como lento.
  • Investigas la causa de una prueba intermitente antes de ocultarla.
  • La suite se ejecuta correctamente con python -m pytest.

21.24 Conclusión

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.