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.

O spec que passava no Karma, agora lançando o errots
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.

CaminhoO que você mudaMelhor quandoCusto
Patch de zone testingAdiciona o patch aos polyfills de teste e mantém fakeAsync/tickSuíte grande que você não consegue reescrever em uma branchExperimental, com duas arestas afiadas
Reescrita para timers do VitestTroca fakeAsync por vi.useFakeTimers() e avança o tempo você mesmoTestes que você já está tocando, e todo teste novoUma 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.

O mesmo patch, dois resultadosts
// 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.

Timer e debounce do RxJS: o avanço síncronots
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']);
});
A armadilha do microtask: Promise na cadeia precisa do avanço assíncronots
// 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 e marble: sem fakeAsync envolvidots
// 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 fakeAsync por vi.useFakeTimers() e chame vi.useRealTimers() no afterEach.
  • Se há uma Promise em qualquer ponto da cadeia (async validator, .then, whenStable), use await 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 no TestScheduler sem 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 no it importado.

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

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 · 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 →

Guia · 10 min

O código que quebra quando Angular fica zoneless

Um guia técnico para revisar timers, subscriptions, forms, callbacks de terceiros e testes antes de um spike zoneless no Angular.

Ler artigo →

Nota · 6 min

O que adotar, testar ou deixar para depois no Angular 21

Um filtro de adoção para Angular 21: o que entra em trechos que já vão mudar, o que merece branch e o que deve ficar longe de fluxos críticos.

Ler artigo →