O teste chama a operação, recebe um erro e espera o backoff inteiro antes de ver a segunda tentativa. No CI, esse caso fica lento. Quando alguém troca a espera por um fake timer, aparece outro problema: o relógio avança, mas a asserção ainda não vê o próximo retry.
Para testar lógica de retry em JavaScript sem esperar, prove três fronteiras separadas: a função foi chamada de novo, o atraso correto foi agendado e a Promise terminou antes da asserção. Um fake timer controla o relógio. Ele não substitui a fila assíncrona nem decide se repetir um efeito externo é seguro.
Este guia usa Vitest nos exemplos e compara as ideias com Jest e node:test.
Os trechos são ilustrativos e não foram executados neste repositório. Eles
mostram a ordem das operações que a sua suíte deve verificar.

Resposta curta
- Passe uma função para o retry, não uma Promise já criada.
- Instale o relógio falso antes de iniciar a operação.
- Avance o timer e aguarde a fila de Promises antes de contar a próxima tentativa.
- Restaure o relógio e teste também o limite final e os efeitos que não podem ser duplicados.
O que um teste de retry precisa provar?
Um teste útil não verifica apenas que a operação acabou com "ok". Ele prova
qual erro pode ser repetido, quantas tentativas acontecem, qual atraso separa
essas tentativas e qual erro chega ao chamador quando o limite termina. O
resultado final é uma parte do contrato, não o contrato inteiro.
Se a operação chama uma API, uma fila ou um banco, acrescente uma pergunta: uma segunda chamada repete uma leitura ou repete um efeito? O artigo sobre escolher a camada certa para cada comportamento ajuda a separar a prova unitária do retry da integração que verifica o serviço real.
Uma sequência mínima pode ser descrita assim:
| Parte do contrato | O que observar |
|---|---|
| Primeira tentativa | A função recebe a entrada esperada. |
| Falha recuperável | O erro agenda um atraso, em vez de encerrar cedo. |
| Próxima tentativa | A função é chamada novamente depois do atraso. |
| Falha terminal | O limite impede outra chamada e preserva o erro. |
| Efeito externo | A repetição não cria uma segunda cobrança, mensagem ou gravação. |
Essa lista também evita um teste enganoso. Se você só espera a Promise final, um retry que tenta vezes demais pode continuar verde. Se você só conta chamadas, um código que tenta imediatamente, sem respeitar o backoff, também passa.
Por que repetir uma Promise pronta produz um teste falso?
O retry precisa receber uma função que cria uma nova tentativa. Uma Promise representa uma execução que já começou. Guardar essa Promise e aguardá-la de novo repete a espera pelo mesmo resultado, não a chamada da operação.
Este primeiro desenho é fácil de escrever e difícil de testar corretamente:
const response = fetchData();
return retry(response, { maxAttempts: 3 });
Depois que fetchData() falha, retry não consegue começar outra requisição.
Ele só recebe o objeto já rejeitado. O formato que preserva a nova tentativa é
este:
return retry(() => fetchData(), { maxAttempts: 3 });
O teste deve tornar essa diferença visível. Configure a função para falhar na primeira chamada e resolver na segunda. Depois confira o contador e o atraso. Se o código aceitar uma Promise pronta, a segunda chamada nunca acontecerá. Essa é uma falha de desenho, não um problema que fake timers conseguem corrigir.
Como testar retry com fake timers no Vitest?
O exemplo precisa de um atraso injetável e de uma operação que possa produzir
resultados diferentes a cada chamada. A documentação do Vitest, “Timers”,
consultada em 2026-09-01, mostra vi.useFakeTimers() e as funções que avançam
o relógio sem esperar pelo tempo 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;
}
O teste abaixo começa a operação sem aguardá-la imediatamente. Assim, ele consegue verificar que a primeira chamada falhou e que o segundo intento ainda depende do timer:
import { afterEach, describe, expect, it, vi } from "vitest";
describe("retry", () => {
afterEach(() => {
vi.useRealTimers();
});
it("avança o backoff antes da próxima tentativa", 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);
});
});
O valor de 1_000 é parte da fixture, não uma recomendação universal de
backoff. O teste prova que a segunda tentativa não ocorre antes do atraso
configurado. Em produção, a política pode usar backoff exponencial, jitter ou
um limite compartilhado. O teste deve refletir a política real, não esconder a
diferença atrás de runAllTimers().
Como avançar o timer sem perder a Promise?
O erro mais comum aparece quando o teste avança o relógio e faz a asserção na mesma linha. O callback do timer pode iniciar uma operação assíncrona, mas o resultado só fica observável depois que a fila de Promises é processada. Por isso, use a variante assíncrona do runner quando ela existir.
No Vitest, vi.advanceTimersByTimeAsync() avança os timers e dá espaço para o
trabalho assíncrono terminar. No Jest, a documentação de “Timer Mocks”,
consultada em 2026-09-01, oferece jest.advanceTimersByTime() e as APIs para
executar timers pendentes. No node:test, o guia do test runner do Node.js,
consultado em 2026-09-01, documenta context.mock.timers.tick() e reset().
| Runner | Controle no teste | Cuidados |
|---|---|---|
| Vitest | vi.useFakeTimers() e vi.advanceTimersByTimeAsync(ms) |
Restaure com vi.useRealTimers(). |
| Jest | jest.useFakeTimers() e a variante assíncrona disponível |
Use jest.useRealTimers() no cleanup. |
node:test |
context.mock.timers.enable() e tick(ms) |
Confirme a versão de Node usada no CI. |
Se o retry agenda outro timer dentro do callback, avance um intervalo por vez
quando quiser verificar cada tentativa. runAllTimers() é adequado para um
conjunto finito e conhecido, mas é perigoso para intervalos permanentes ou
loops sem limite. A própria documentação do Jest recomenda timers pendentes
quando o callback agenda outro timer.
O artigo sobre separar a data dos timers no teste explica a mesma distinção em mais detalhes. A data observada, o timer que agenda trabalho e a Promise que entrega o resultado podem ser dependências diferentes.
O que testar quando todas as tentativas falham?
O caso de sucesso após um erro cobre apenas uma transição. Adicione um caso que falha sempre e confirme que o retry termina no limite, devolve o erro certo e não agenda um quarto intento. Se a implementação engole o erro ou reinicia o contador a cada chamada, esse caso revela o defeito.
it("para no limite e preserva o erro 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);
});
Se o runner não oferece runAllTimersAsync, avance os atrasos esperados e
aguarde a Promise após cada passo. O teste fica mais comprido, mas mostra qual
evento libera cada tentativa. Para um loop que pode crescer indefinidamente,
não use uma função que tenta esvaziar todos os timers. Defina um limite no
código e faça o teste parar nesse limite.
Como testar retry sem duplicar efeitos externos?
Uma função de leitura pode ser chamada novamente depois de um timeout. Uma função que envia mensagem, grava pagamento ou altera estoque precisa de outra prova: o timeout informa que a resposta não chegou, não que o efeito não aconteceu. O teste determinístico deve usar um fake ou um adaptador controlado para simular o estado ambíguo.
Esse limite fica fora do fake timer. Teste separadamente se a execução conserva um identificador idempotente, consulta o estado conhecido ou interrompe o fluxo antes de repetir. Para chamadas de ferramentas, validar a fronteira antes do retry mostra por que o erro precisa carregar estado suficiente para uma decisão.
Um teste de retry confiável mede duas coisas diferentes: o scheduler repetiu a tentativa no momento certo e o domínio permitiu repetir aquela operação. Juntar as duas em um teste que chama uma API real costuma produzir uma resposta ambígua. O timer pode estar correto enquanto a proteção contra efeito duplicado continua ausente.
Como verificar e limpar o teste?
Antes de colocar o caso no CI, faça a revisão nesta ordem:
- Passe uma função para o retry e confirme que cada chamada cria uma tentativa.
- Instale fake timers antes de iniciar a operação ou importar um módulo que capture o relógio.
- Confirme a quantidade de chamadas antes e depois de cada avanço.
- Use a API assíncrona do runner quando o callback iniciar uma Promise.
- Teste o sucesso, a falha terminal e o atraso que não deve ser ignorado.
- Restaure timers reais mesmo quando uma asserção falhar.
- Separe a prova do scheduler da prova de idempotência e do contrato da API.
Rode primeiro o caso isolado e depois a suíte completa. Se ele falha somente na suíte, procure vazamento de relógio, intervalos pendentes ou estado compartilhado. O artigo sobre evitar estado compartilhado no CI trata da mesma classe de isolamento em testes de browser.
Testar retry sem esperar não significa acelerar qualquer teste com um botão. Significa controlar o atraso que pertence ao contrato e aguardar o trabalho que pertence à Promise. Quando essas fronteiras aparecem no teste, uma suíte verde fica mais informativa: ela diz quantas tentativas ocorreram, quando ocorreram e qual resultado ainda pode ser repetido com segurança.
Fontes consultadas
- Vitest, “Timers”, consultado em 2026-09-01.
- Jest, “Timer Mocks”, consultado em 2026-09-01.
- Node.js, “Test runner”, consultado em 2026-09-01.
- Stack Overflow, “How do I test a recursive, asynchronous JavaScript function using fake timers?”, consultado em 2026-09-01. Discussão usada apenas para identificar a falha recorrente de observabilidade.
- Vitest, “Unsure how to utilize vi.advanceTimers with recursion”, consultado em 2026-09-01. Discussão usada como sinal de linguagem e failure mode, não como prova técnica.