Por que seu async validator fica PENDING com os fake timers do Vitest

Esse não lança erro. O teste roda, nada quebra, e a assertion falha num valor que deveria estar lá: o control ainda está PENDING. É o primo silencioso do erro ProxyZone, e a correção é uma palavra só. Os fake timers do Vitest disparam timers mas não drenam os microtasks de Promise, então o resultado da validação nunca chega antes do seu expect rodar.

A falha que parece impossível

Não há erro de ProxyZone aqui. O spec roda, nada quebra, e então ele falha num valor que obviamente deveria estar setado. O async validator era pra marcar o control como inválido, mas control.status ainda está PENDING e control.errors é null. Nada na saída explica o porquê.

Isso aparece de duas formas: um teste de fakeAsync que você migrou no mecânico (trocou tick por vi.advanceTimersByTime e deu por encerrado), ou um teste novo que controla tempo com fake timers. De qualquer jeito, o timer disparou. O resultado é que ainda não está lá.

Roda limpo, falha mesmo assimts
it('marca username em uso', () => {
  vi.useFakeTimers();
  const control = new FormControl('taken', {
    asyncValidators: [uniqueUsernameValidator(['taken'])],
  });

  vi.advanceTimersByTime(200);          // dispara o timer, não o microtask

  expect(control.status).toBe('INVALID'); // recebido: 'PENDING'
});

Por quê: um timer não é um microtask

O vi.advanceTimersByTime() dispara timers, de forma síncrona. Um timer é um macrotask: setTimeout, setInterval, e os schedulers do RxJS que pegam carona neles. O debounce do async validator é um timer, então o avanço de fato o dispara.

Mas o resultado do validator é aplicado quando a Promise dele resolve, e a resolução de uma Promise é um microtask, não um timer. O avanço síncrono nunca drena a fila de microtasks, então na hora do seu expect, o callback da Promise que setaria o erro ainda não rodou. O control ainda está PENDING. A falha parece impossível porque o validator rodou sim. A resposta dele está só parada numa fila que você nunca esvaziou.

MecanismoTipoDisparado pelo advanceTimersByTime (sync)?
setTimeout, setIntervalMacrotask (timer)Sim
RxJS debounceTime, async schedulerMacrotask (carona no setInterval)Sim
Promise.then, resultado de async validator, whenStableMicrotaskNão, precisa do avanço assíncrono

A correção é uma palavra: Async

Troque advanceTimersByTime por advanceTimersByTimeAsync, faça o teste async, e dê await. A variante assíncrona avança o tempo e drena os microtasks no meio, então a Promise resolve e o resultado chega antes da sua assertion. Essa é a correção inteira.

O avanço assíncrono drena o microtaskts
it('marca username em uso', async () => {        // agora async
  vi.useFakeTimers();
  const control = new FormControl('taken', {
    asyncValidators: [uniqueUsernameValidator(['taken'])],
  });

  await vi.advanceTimersByTimeAsync(200);          // tempo E microtasks

  expect(control.status).toBe('INVALID');          // passa
  expect(control.errors).toEqual({ taken: true });
});

É Promise, não async em geral

Aqui é onde as pessoas exageram na correção e começam a dar await em tudo. Não devem. Nem todo teste de cara assíncrona precisa do avanço assíncrono. Um stream RxJS que alimenta uma chamada HttpClient é síncrono dentro de um teste: o vi.advanceTimersByTime dispara o debounce, e o HttpTestingController.flush() aplica a resposta de forma síncrona. Não há Promise nessa cadeia, então não há microtask a drenar.

A armadilha é especificamente uma Promise: um async validator, um .then, um whenStable(), ou um método de service que retorna uma Promise. Viu um desses, pega o avanço assíncrono. Quando a cadeia é RxJS e HttpClient de ponta a ponta, o avanço síncrono e um flush() estão certos, e adicionar await só esconde o que está de fato acontecendo.

RxJS mais HttpClient continua síncronots
const httpMock = TestBed.inject(HttpTestingController);

vi.advanceTimersByTime(300);                 // dispara o debounce -> http.get
const req = httpMock.expectOne('/api/search?q=ab');
req.flush([{ id: 1, label: 'Abacaxi' }]);    // aplicado de forma síncrona, sem Promise

expect(seen).toEqual([[{ id: 1, label: 'Abacaxi' }]]);

Como reconhecer em cinco segundos

O sinal é o formato da falha: o teste roda, nada quebra, e um valor está simplesmente faltando (PENDING, null, undefined) onde deveria estar setado. No instante em que você suspeita de tempo, faça uma pergunta. Há uma Promise na cadeia? Se sim, troque advanceTimersByTime por await advanceTimersByTimeAsync. Se o valor aparece, era isso.

Essa é a metade silenciosa do mesmo problema que o erro ProxyZone anuncia em alto e bom som, e os dois vêm do split que os fake timers do Vitest deixam explícito: tempo é um trabalho, microtasks são outro.

Artefato para reaproveitar

Diagnosticando uma falha silenciosa de tempo no Vitest

  • Sintoma: o teste roda, nada quebra, mas um valor está faltando (PENDING, null, undefined).
  • Procure uma Promise na cadeia: async validator, .then, whenStable, service que retorna Promise.
  • Se houver, faça o teste async e use await vi.advanceTimersByTimeAsync(ms).
  • Se for RxJS mais HttpTestingController de ponta a ponta, mantenha o avanço síncrono e o flush(); não há microtask a drenar.
  • Ainda travado? Tente await vi.runAllTimersAsync() para drenar todos os timers pendentes e seus microtasks.
  • Resete com vi.useRealTimers() no afterEach.

Perguntas frequentes

Por que meu async validator do Angular fica PENDING num teste Vitest?

Porque o vi.advanceTimersByTime() dispara o timer do debounce mas não drena o microtask de Promise que aplica o resultado da validação. O control resolve num microtask que não rodou antes da sua assertion, então fica PENDING. Use await vi.advanceTimersByTimeAsync(ms), que avança o tempo e drena os microtasks.

O advanceTimersByTime não funciona, o valor continua undefined. Por quê?

Ele funciona, mas só nos timers. Qualquer coisa aplicada por uma Promise (um async validator, um .then, whenStable) cai num microtask, e o avanço síncrono não drena microtasks. Troque pela variante assíncrona e dê await.

Qual a diferença entre um timer e um microtask aqui?

Um timer (setTimeout, setInterval, e os schedulers do RxJS em cima deles) é um macrotask, e o advanceTimersByTime o dispara. A resolução de uma Promise é um microtask, drenado só pelo avanço assíncrono (advanceTimersByTimeAsync, runAllTimersAsync) ou por um await real.

Meus testes de HttpClient precisam do advanceTimersByTimeAsync também?

Em geral não. Um stream RxJS alimentando HttpClient é síncrono num teste: avance o timer pro debounce, e o HttpTestingController.flush() aplica a resposta de forma síncrona. Você só precisa do avanço assíncrono se uma Promise estiver na cadeia, por exemplo um .then depois da resposta.

O runAllTimers conserta um validator PENDING?

Não, use a versão assíncrona: await vi.runAllTimersAsync(). Os helpers de timer assíncronos drenam microtasks entre os timers, que é o que deixa a Promise do validator resolver. O runAllTimers síncrono tem o mesmo ponto cego do advanceTimersByTime.

Fontes consultadas

Modern Angular Playbook

Este artigo é um play.

O Playbook do Angular Moderno reúne o diagnóstico, a matriz de adoção, onze jogadas e o plano de 30 dias. Grátis, em inglês e português.

Você recebe os dois PDFs por email, pela lista do Dojo IA.

Abrir playbook →

André Ramosdisponível para vagas remotas, UTC−3Entre em contato →

Leia também

Guia · 9 min

fakeAsync e o erro ProxyZone: consertando testes Angular no Vitest

Por que fakeAsync, tick e waitForAsync lançam o erro ProxyZone no runner Vitest do Angular, e as duas correções: o patch experimental de zone testing, ou a reescrita para fake timers do Vitest.

Ler artigo →

Guia · 7 min

Fake timers do Vitest no Angular: o modelo mental depois do fakeAsync

Como pensar nos fake timers do Vitest no Angular depois do fakeAsync: os dois trabalhos que o fakeAsync fundia, o mapa de API, as partes específicas do Angular, e quando não usar fake timers.

Ler artigo →

Guia · 6 min

Quando migrar uma suíte Angular para Vitest

Vitest virou o test runner padrão do Angular CLI, mas migrar uma suíte existente ainda é experimental: quais specs migrar primeiro, o que o fakeAsync quebra, e como manter o CI confiável.

Ler artigo →