La prueba llama a la operación, recibe un error y espera todo el backoff antes de ver el segundo intento. En CI, el caso se vuelve lento. Cuando alguien reemplaza la espera por un fake timer, aparece otro problema: el reloj avanza, pero la aserción todavía no puede ver el siguiente reintento.
Para probar la lógica de reintentos en JavaScript sin esperar, demuestra tres fronteras separadas: la función volvió a llamarse, se programó el retraso correcto y la Promise se resolvió antes de la aserción. Un fake timer controla el reloj. No reemplaza la cola asíncrona ni decide si repetir un efecto externo es seguro.
Esta guía usa Vitest en los ejemplos y compara las ideas con Jest y node:test.
Los fragmentos son ilustrativos y no se ejecutaron en este repositorio. Muestran
el orden que tu suite debe comprobar.

Respuesta corta
- Pasa una función al retry, no una Promise que ya empezó.
- Instala el reloj falso antes de iniciar la operación.
- Avanza el timer y deja que se resuelva la cola de Promises antes de contar el siguiente intento.
- Restaura el reloj y prueba el límite final y los efectos que no deben duplicarse.
¿Qué debe demostrar una prueba de reintentos?
Una prueba útil no comprueba solo que la operación terminó con "ok". Demuestra
qué error se puede repetir, cuántos intentos ocurren, qué retraso hay entre
ellos y qué error recibe el llamador cuando termina el límite. El resultado
final es una parte del contrato, no todo el contrato.
Si la operación llama a una API, una cola o una base de datos, añade otra pregunta: ¿la segunda llamada repite una lectura o repite un efecto? La visión general de los tipos de pruebas ayuda a separar la prueba unitaria del retry y la integración que comprueba el servicio real.
Un contrato pequeño puede describirse así:
| Parte del contrato | Qué observar |
|---|---|
| Primer intento | La función recibe la entrada esperada. |
| Fallo recuperable | El error programa un retraso en lugar de terminar pronto. |
| Siguiente intento | La función vuelve a llamarse después del retraso. |
| Fallo terminal | El límite impide otra llamada y conserva el error. |
| Efecto externo | Repetir no crea un segundo cobro, mensaje o escritura. |
Esta lista también evita una prueba engañosa. Si solo esperas la Promise final, un retry que intenta demasiadas veces puede seguir en verde. Si solo cuentas llamadas, el código que reintenta de inmediato, sin respetar el backoff, también puede pasar.
¿Por qué repetir una Promise lista crea una prueba falsa?
El helper de retry necesita una función que cree un intento nuevo. Una Promise representa una ejecución que ya comenzó. Guardar esa Promise y esperarla otra vez espera el mismo resultado, pero no vuelve a llamar a la operación.
Esta primera forma es fácil de escribir y difícil de probar correctamente:
const response = fetchData();
return retry(response, { maxAttempts: 3 });
Después de que fetchData() falla, retry no puede empezar otra petición.
Recibió el objeto rechazado que ya existe. La forma que conserva un intento
nuevo es esta:
return retry(() => fetchData(), { maxAttempts: 3 });
Haz visible la diferencia en la prueba. Configura la función para rechazar en la primera llamada y resolver en la segunda. Comprueba tanto el contador como el retraso. Si el código acepta una Promise lista, la segunda llamada no puede ocurrir. Es un error de diseño, no algo que los fake timers puedan reparar.
¿Cómo probar reintentos con fake timers de Vitest?
El ejemplo necesita un retraso inyectable y una operación que pueda devolver un
resultado diferente en cada llamada. La guía de timers de Vitest,
consultada el 2026-09-01, documenta vi.useFakeTimers() y las funciones que
avanzan el reloj sin esperar el tiempo real.
type RetryOptions = {
maxAttempts: number;
delayMs: number;
};
export async function retry<T>(
operation: () => Promise<T>,
options: RetryOptions,
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === options.maxAttempts) {
break;
}
await new Promise<void>((resolve) => {
setTimeout(resolve, options.delayMs * attempt);
});
}
}
throw lastError;
}
La prueba inicia la operación sin esperar de inmediato el resultado final. Así puede comprobar que la primera llamada falló y que el segundo intento aún depende del timer:
import { afterEach, describe, expect, it, vi } from "vitest";
describe("retry", () => {
afterEach(() => {
vi.useRealTimers();
});
it("avanza el backoff antes del siguiente intento", async () => {
vi.useFakeTimers();
const operation = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(new Error("temporary"))
.mockResolvedValueOnce("ok");
const result = retry(operation, { maxAttempts: 3, delayMs: 1_000 });
await Promise.resolve();
expect(operation).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
await expect(result).resolves.toBe("ok");
expect(operation).toHaveBeenCalledTimes(2);
});
});
El valor 1_000 pertenece al fixture. No es una recomendación universal de
backoff. La prueba demuestra que el segundo intento no ocurre antes del retraso
configurado. El código de producción puede usar backoff exponencial, jitter o
un límite compartido. La prueba debe reflejar esa política, no esconder la
diferencia detrás de runAllTimers().
¿Cómo avanzar el timer sin perder la Promise?
El fallo común consiste en avanzar el reloj y hacer la aserción en la línea siguiente. El callback del timer puede iniciar trabajo asíncrono, pero el resultado se vuelve observable solo después de procesar la cola de Promises. Usa la API asíncrona del runner cuando exista.
En Vitest, vi.advanceTimersByTimeAsync() avanza los timers y deja que el
trabajo asíncrono se resuelva. La guía de Timer Mocks de Jest,
consultada el 2026-09-01, documenta jest.advanceTimersByTime() y APIs para
ejecutar timers pendientes. En node:test, la guía del test runner de Node.js,
consultada el 2026-09-01, documenta context.mock.timers.tick() y reset().
| Runner | Control en la prueba | Punto de cuidado |
|---|---|---|
| Vitest | vi.useFakeTimers() y vi.advanceTimersByTimeAsync(ms) |
Restaura con vi.useRealTimers(). |
| Jest | jest.useFakeTimers() y la variante asíncrona disponible |
Llama jest.useRealTimers() durante la limpieza. |
node:test |
context.mock.timers.enable() y tick(ms) |
Confirma la versión de Node usada en CI. |
Si el retry programa otro timer dentro de su callback, avanza un intervalo cada
vez que quieras inspeccionar cada intento. runAllTimers() sirve para un
conjunto finito y conocido, pero es arriesgado con intervalos permanentes o
bucles sin límite. La documentación de Jest recomienda métodos de timers
pendientes cuando un callback programa otro timer.
El artículo sobre separar fechas y timers en una prueba explica la misma diferencia con más detalle. La fecha observada, el timer que programa trabajo y la Promise que entrega el resultado pueden ser dependencias distintas.
¿Qué probar cuando todos los intentos fallan?
El caso que tiene éxito después de un error cubre una sola transición. Añade un caso que falle siempre y confirma que el retry se detiene en el límite, devuelve el error correcto y no programa un cuarto intento. Si la implementación oculta el error o reinicia el contador en cada llamada, este caso lo muestra.
it("se detiene en el límite y conserva el error final", async () => {
vi.useFakeTimers();
const error = new Error("permanent");
const operation = vi.fn<() => Promise<never>>().mockRejectedValue(error);
const result = retry(operation, { maxAttempts: 3, delayMs: 100 });
await vi.runAllTimersAsync();
await expect(result).rejects.toBe(error);
expect(operation).toHaveBeenCalledTimes(3);
});
Si el runner no ofrece runAllTimersAsync, avanza los retrasos esperados y
espera la Promise después de cada paso. La prueba es más larga, pero muestra
qué evento libera cada intento. Para un bucle que puede crecer para siempre, no
uses una función que intente vaciar todos los timers. Define un límite en el
código y haz que la prueba se detenga en ese límite.
¿Cómo probar reintentos sin duplicar efectos externos?
Una lectura puede ejecutarse otra vez después de un timeout. Una función que envía un mensaje, registra un pago o cambia el inventario necesita otra prueba: un timeout indica que la respuesta no llegó, no que el efecto no ocurrió. La prueba determinista debe usar un fake o un adaptador controlado para simular el estado ambiguo.
Esa frontera queda fuera de los fake timers. Comprueba por separado si la ejecución conserva una clave de idempotencia, consulta el estado conocido o se detiene antes de repetir el efecto. Para tool calls, valida la frontera antes de reintentar explica por qué un error necesita suficiente estado para tomar una decisión.
Una prueba de reintentos confiable mide dos cosas distintas: el scheduler repitió el intento en el momento correcto y el dominio permitió repetir esa operación. Combinar ambas en una prueba que llama a una API real suele producir un resultado ambiguo. El timer puede estar correcto mientras falta la protección contra efectos duplicados.
¿Cómo verificar y limpiar la prueba?
Antes de poner el caso en CI, revísalo en este orden:
- Pasa una función al retry y confirma que cada llamada crea un intento.
- Instala fake timers antes de iniciar la operación o importar un módulo que capture el reloj.
- Confirma el número de llamadas antes y después de cada avance.
- Usa la API asíncrona del runner cuando el callback inicie una Promise.
- Prueba el éxito, el fallo terminal y el retraso que no se debe saltar.
- Restaura los timers reales aunque falle una aserción.
- Separa la prueba del scheduler de la idempotencia y del contrato de la API.
Ejecuta primero el caso aislado y después la suite completa. Si falla solo en la suite, busca un reloj filtrado, un intervalo pendiente o estado compartido. El artículo sobre evitar estado compartido en CI trata la misma clase de aislamiento en pruebas de navegador.
Probar reintentos sin esperar no significa acelerar cualquier prueba con un botón. Significa controlar el retraso que pertenece al contrato y esperar el trabajo que pertenece a la Promise. Cuando esas fronteras aparecen en la prueba, una suite verde informa mejor: dice cuántos intentos ocurrieron, cuándo ocurrieron y qué resultado todavía se puede repetir con seguridad.
Fuentes consultadas
- Vitest, “Timers”, consultado el 2026-09-01.
- Jest, “Timer Mocks”, consultado el 2026-09-01.
- Node.js, “Test runner”, consultado el 2026-09-01.
- Stack Overflow, “How do I test a recursive, asynchronous JavaScript function using fake timers?”, consultado el 2026-09-01. Se usó solo para identificar un fallo recurrente de observabilidad.
- Vitest, “Unsure how to utilize vi.advanceTimers with recursion”, consultado el 2026-09-01. Se usó como evidencia de lenguaje y failure mode, no como prueba técnica.