Modern Angular
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á.
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.
| Mecanismo | Tipo | Disparado pelo advanceTimersByTime (sync)? |
|---|---|---|
setTimeout, setInterval | Macrotask (timer) | Sim |
RxJS debounceTime, async scheduler | Macrotask (carona no setInterval) | Sim |
Promise.then, resultado de async validator, whenStable | Microtask | Nã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.
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.
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
asynce useawait vi.advanceTimersByTimeAsync(ms). - Se for RxJS mais
HttpTestingControllerde ponta a ponta, mantenha o avanço síncrono e oflush(); 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()noafterEach.
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
- https://angular.dev/guide/testing
- https://angular.dev/guide/testing/migrating-to-vitest
- https://angular.dev/api/core/testing/TestBed
- https://vitest.dev/api/vi
- https://vitest.dev/guide/mocking
- https://blog.angular.dev/announcing-angular-v21-57946c34f14b
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.
André Ramosdisponível para vagas remotas, UTC−3Entre em contato →