Modern Angular
fakeAsync e o erro ProxyZone: consertando testes Angular no Vitest
Vitest é o test runner padrão do Angular desde a v21, e a maioria dos specs vem junto sem reclamar. Os que mexem com tempo não: fakeAsync, tick, flush e waitForAsync lançam Expected to be running in 'ProxyZone', but it was not found. Existem duas correções suportadas, e o guia oficial de migração deixa claro qual ele prefere.
O erro, e por que ele acontece
Você troca o test runner do Angular para Vitest, roda a suíte, e a maior parte passa. Aí todo spec que controla tempo falha com a mesma mensagem: Expected to be running in 'ProxyZone', but it was not found. O stack aponta para zone-testing.js, não para o seu código.
A causa é bem específica. fakeAsync, tick, flush e waitForAsync não controlam tempo sozinhos. Eles delegam para uma Zone chamada ProxyZone, que o zone.js/testing instala em volta de cada spec. O setup do Karma fazia isso por você no test.ts. O builder do Vitest roda seus testes, mas não envolve cada um na ProxyZone, então o helper verifica que a zone existe e para antes do corpo do seu teste rodar.
Não é a lógica do seu teste falhando. O mesmo spec passou no Karma ontem. O que mudou foi o aparato de teste em volta dele, o que significa que a correção é de configuração ou reescrita, não caça a bug na assertion.
import { fakeAsync, tick } from '@angular/core/testing';
it('limpa o toast depois do timeout', fakeAsync(() => {
let message = 'saving';
setTimeout(() => (message = ''), 1000);
tick(1000);
expect(message).toBe('');
}));
// Error: Expected to be running in 'ProxyZone', but it was not found.
// at assertPresent (node_modules/zone.js/fesm2015/zone-testing.js)Dois caminhos de saída, e qual o Angular recomenda
Existem exatamente duas respostas suportadas, e elas não são iguais. Você pode manter seus testes baseados em Zone.js vivos com um patch experimental, ou reescrevê-los para controlar tempo com os fake timers do próprio Vitest.
O guia oficial é explícito sobre a direção. Ele recomenda converter para async nativo e fake timers do Vitest, e chama isso de abordagem estabelecida. O patch existe para que uma suíte grande não trave o upgrade inteiro no primeiro dia, não como o lugar onde você quer ficar.
| Caminho | O que você muda | Melhor quando | Custo |
|---|---|---|---|
| Patch de zone testing | Adiciona o patch aos polyfills de teste e mantém fakeAsync/tick | Suíte grande que você não consegue reescrever em uma branch | Experimental, com duas arestas afiadas |
| Reescrita para timers do Vitest | Troca fakeAsync por vi.useFakeTimers() e avança o tempo você mesmo | Testes que você já está tocando, e todo teste novo | Uma passada mecânica por teste, mas durável |
A saída de emergência: o patch de zone testing
Se você precisa da suíte verde hoje, o Angular traz um patch opt-in. Na configuração de build de teste, deixe os polyfills como ["zone.js", "zone.js/testing", "zone.js/plugins/vitest-patch"]. A ordem importa: o zone.js/testing registra o ProxyZoneSpec que o patch envolve, então ele precisa carregar primeiro. Com a ordem errada, você troca o erro de ProxyZone por Missing ProxyZoneSpec.
A segunda aresta é mais silenciosa e me custou uma sessão real de debug. O patch envolve o it, o describe e o beforeEach globais do Vitest. Um spec que os importa com import { it } from 'vitest' se liga a outra função e pula o wrapper, então fakeAsync continua lançando mesmo com o patch instalado. Para usar o patch, a suíte tem que rodar nos globais do Vitest, não em funções de teste importadas.
Eu trato isso como paliativo. Ele carrega uma flag de experimental e vai contra a direção que o time Angular aponta, então recorro a ele pra desbloquear uma suíte grande e vou convertendo os testes conforme mexo neles.
// Patch instalado, mas isso AINDA lança: o `it` importado pula o wrapper.
import { it } from 'vitest';
it('salva', fakeAsync(() => { tick(1000); /* erro de ProxyZone */ }));
// Funciona: o `it` global é o que o patch envolveu (globais do Vitest).
it('salva', fakeAsync(() => {
const fixture = TestBed.createComponent(SaveIndicatorComponent);
fixture.componentInstance.save();
tick(1000);
expect(fixture.nativeElement.textContent).toContain('saved');
}));A reescrita, padrão por padrão
O caminho durável troca fakeAsync por fake timers do Vitest e avança o tempo você mesmo. A forma é sempre a mesma: vi.useFakeTimers() no topo, avança, verifica, e vi.useRealTimers() no afterEach para os timers não vazarem entre testes. A parte que vale saber é quais padrões escondem uma armadilha. O Angular 22 adicionou um schematic refactor-jasmine-vitest --fake-async que faz a versão mecânica disso por você, então leia as conversões abaixo como o que conferir na saída dele, e a armadilha do microtask como a decisão que ele não consegue tomar.
Um timer simples é mecânico. vi.advanceTimersByTime(300) o dispara de forma síncrona, e um debounceTime do RxJS pega carona no mesmo timer, porque o scheduler dele roda em setInterval.
A armadilha é uma Promise na cadeia: um async validator, um .then, um whenStable. Ali, o avanço síncrono dispara o timer mas não drena o microtask que aplica o resultado, então o control fica PENDING e a assertion falha por um motivo que não parece ter nada a ver com tempo. A correção é o avanço assíncrono, await vi.advanceTimersByTimeAsync(...), que drena os dois.
Dois padrões não precisam de timer nenhum. Effects de signal são processados durante o change detection, então rode eles com TestBed.tick() (TestBed.flushEffects() está deprecado). Testes marble usam TestScheduler, que nunca dependeu de Zone.js, então seguem passando sem mudança.
afterEach(() => vi.useRealTimers());
it('debounce de termos rápidos até o último', () => {
vi.useFakeTimers();
const service = TestBed.inject(SearchService);
const seen: string[] = [];
service.results$.subscribe((t) => seen.push(t));
service.search('a');
service.search('ang');
vi.advanceTimersByTime(300);
expect(seen).toEqual(['ang']);
});// O advanceTimersByTime síncrono dispara o timer mas deixa o control PENDING,
// porque não drena o microtask que escreve o resultado da validação.
it('marca username em uso depois da checagem assíncrona', async () => {
vi.useFakeTimers();
const control = new FormControl('taken', {
asyncValidators: [uniqueUsernameValidator(['taken', 'admin'])],
});
await vi.advanceTimersByTimeAsync(200);
expect(control.status).toBe('INVALID');
expect(control.errors).toEqual({ taken: true });
});// Effects sao processados no change detection: dirija com TestBed.tick().
TestBed.tick();
expect(service.history).toEqual(['idle']);
// Testes marble rodam no TestScheduler, independente de Zone.js, então
// seguem funcionando sem mudanca. Para streams com muito tempo, fica mais limpo.
scheduler.run(({ cold, expectObservable }) => {
const source = cold('-a-b-c|', { a: 1, b: 2, c: 3 });
expectObservable(source.pipe(map((x) => x * 10))).toBe('-a-b-c|', { a: 10, b: 20, c: 30 });
});O que eu faria de verdade
A decisão é por teste, não por suíte. Um teste que você já está editando: reescreva. São poucas linhas, e tira uma dependência de Zone.js que você não vai sentir falta. Uma suíte grande que ninguém está tocando: instale o patch, mantenha o CI verde, e converta os testes conforme as features te trouxerem de volta a eles. Um teste novo: escreva com fake timers do Vitest desde o início e nunca encontre o erro de ProxyZone.
Isso fica um degrau abaixo da pergunta anterior, a de se vale migrar a suíte, tratada em Quando migrar uma suíte Angular para Vitest. E os padrões aqui são os mesmos que aparecem quando um app vira zoneless, porque os dois removem a Zone que antes absorvia o tempo por você.
Artefato para reaproveitar
Checklist de conversão de fakeAsync para Vitest
- Confirme que a falha é o erro de ProxyZone, não uma assertion real: o mesmo spec passava no Karma.
- Escolha o caminho por teste: patch para suíte grande intocada, reescrita para os testes que você de fato toca.
- Para reescrever, troque
fakeAsyncporvi.useFakeTimers()e chamevi.useRealTimers()noafterEach. - Se há uma Promise em qualquer ponto da cadeia (async validator,
.then,whenStable), useawait vi.advanceTimersByTimeAsync(): o avanço síncrono deixa o estado pendente. - Em componentes OnPush, chame
detectChanges()depois de avançar o tempo, já que o timer marca a view como suja mas não a renderiza. - Dirija effects de signal com
TestBed.tick(), e deixe os testes marble noTestSchedulersem mudança. - Se optar pelo patch, ordene os polyfills
zone.js,zone.js/testing,zone.js/plugins/vitest-patch, e rode nos globais do Vitest, não noitimportado.
Perguntas frequentes
Por que meus testes Angular lançam "Expected to be running in 'ProxyZone'" depois de migrar para Vitest?
Porque fakeAsync, tick, flush e waitForAsync precisam de uma Zone chamada ProxyZone que o zone.js/testing instala em volta de cada spec, e o builder do Vitest não envolve seus testes nela por padrão. Os mesmos specs passavam no Karma porque o setup do Karma instalava essa zone. Você corrige adicionando o patch de zone testing aos polyfills de teste, ou reescrevendo os testes para usar fake timers do Vitest.
Dá para continuar usando fakeAsync no Vitest?
Dá, com o patch experimental. Deixe os polyfills de build de teste como ["zone.js", "zone.js/testing", "zone.js/plugins/vitest-patch"] nessa ordem, e garanta que a suíte usa o it e o describe globais do Vitest, não os importados, porque o patch envolve os globais. O Angular marca isso como experimental e recomenda converter para async nativo e timers do Vitest.
O que substitui tick() e flush() em um teste Vitest?
Use vi.useFakeTimers() com vi.advanceTimersByTime(ms) para tempo síncrono, e await vi.advanceTimersByTimeAsync(ms) quando há uma Promise na cadeia. Chame vi.useRealTimers() no afterEach para os timers não vazarem entre testes.
Meu async validator fica PENDING depois que avanço os timers. Por quê?
Porque o vi.advanceTimersByTime síncrono dispara o timer mas não drena o microtask que aplica o resultado da validação ao control. Use await vi.advanceTimersByTimeAsync(ms), que drena o timer e o microtask, então o control resolve para VALID ou INVALID.
Testes marble do RxJS ainda funcionam no Vitest?
Sim. O TestScheduler não depende de Zone.js nem de fakeAsync, então testes marble seguem funcionando sem mudança. Para streams com muito tempo, costumam ser mais limpos que fake timers.
Fontes consultadas
- https://angular.dev/guide/testing
- https://angular.dev/guide/testing/migrating-to-vitest
- https://angular.dev/api/core/testing/fakeAsync
- 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 →