fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest

Vitest is the default Angular test runner since v21, and most specs come along quietly. The ones that bend time do not: fakeAsync, tick, flush, and waitForAsync throw Expected to be running in 'ProxyZone', but it was not found. There are two supported fixes, and the official migration guide is clear about which one it prefers.

The error, and why it happens

You switch the Angular test runner to Vitest, run the suite, and most of it passes. Then every spec that controls time fails with the same message: Expected to be running in 'ProxyZone', but it was not found. The stack points at zone-testing.js, not at your code.

The cause is narrow. fakeAsync, tick, flush, and waitForAsync do not control time on their own. They delegate to a Zone called ProxyZone, which zone.js/testing installs around each spec. The Karma setup did that for you in test.ts. The Vitest builder runs your tests, but it does not wrap each one in the ProxyZone, so the helper asserts the zone is present and stops before your test body runs.

This is not your test logic failing. The same spec passed under Karma yesterday. What changed is the harness around it, which means the fix is configuration or a rewrite, not a hunt for a bug in the assertion.

The spec that passed under Karma, now throwingts
import { fakeAsync, tick } from '@angular/core/testing';

it('clears the toast after the 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)

Two ways out, and which one Angular recommends

There are exactly two supported answers, and they are not equal. You can keep your Zone.js based tests alive with an experimental patch, or you can rewrite them to control time with Vitest's own fake timers.

The official guide is explicit about the direction. It recommends converting to native async and Vitest fake timers, and calls that the established approach. The patch exists so a large suite does not block the whole upgrade on day one, not as the place you want to stay.

PathWhat you changeBest whenCost
Zone testing patchAdd the zone patch to the test polyfills and keep fakeAsync/tickA large suite you cannot rewrite in one branchExperimental, with two sharp edges
Rewrite to Vitest timersReplace fakeAsync with vi.useFakeTimers() and advance time yourselfTests you already touch, and every new testA mechanical pass per test, but durable

The escape hatch: the zone testing patch

If you need the suite green today, Angular ships an opt-in patch. In your test build configuration, set the polyfills to ["zone.js", "zone.js/testing", "zone.js/plugins/vitest-patch"]. The order matters: zone.js/testing registers the ProxyZoneSpec that the patch wraps, so it has to load first. Get the order wrong and you trade the ProxyZone error for Missing ProxyZoneSpec.

The second edge is quieter and cost me a real debugging session. The patch wraps Vitest's global it, describe, and beforeEach. A spec that imports them with import { it } from 'vitest' binds to a different function and skips the wrapper, so fakeAsync keeps throwing even with the patch installed. To use the patch, the suite has to run on Vitest globals, not imported test functions.

I treat this as a stopgap. It carries an experimental flag and runs against the direction the Angular team is steering, so I reach for it to unblock a big suite and then convert tests as I touch them.

Same patch, two outcomests
// Patch installed, but this STILL throws: the imported `it` skips the wrapper.
import { it } from 'vitest';
it('saves', fakeAsync(() => { tick(1000); /* ProxyZone error */ }));

// Works: the global `it` is the one the patch wrapped (Vitest globals).
it('saves', fakeAsync(() => {
  const fixture = TestBed.createComponent(SaveIndicatorComponent);
  fixture.componentInstance.save();
  tick(1000);
  expect(fixture.nativeElement.textContent).toContain('saved');
}));

The rewrite, pattern by pattern

The durable path swaps fakeAsync for Vitest fake timers and advances time yourself. The shape is always the same: vi.useFakeTimers() at the top, advance, assert, and vi.useRealTimers() in afterEach so timers do not leak between tests. The part worth knowing is which patterns hide a trap. Angular 22 added a refactor-jasmine-vitest --fake-async schematic that does the mechanical version of this for you, so read the conversions below as what to check in its output, and the microtask trap as the call it cannot make.

A plain timer is mechanical. vi.advanceTimersByTime(300) fires it synchronously, and an RxJS debounceTime rides the same timer because its scheduler runs on setInterval.

The trap is a Promise in the chain: an async validator, a .then, a whenStable. There, the synchronous advance fires the timer but does not drain the microtask that applies the result, so the control stays PENDING and the assertion fails for a reason that looks nothing like timing. The fix is the async advance, await vi.advanceTimersByTimeAsync(...), which flushes both.

Two patterns need no timer work at all. Signal effects flush during change detection, so drive them with TestBed.tick() (TestBed.flushEffects() is deprecated). RxJS marble tests use TestScheduler, which never depended on Zone.js, so they keep passing unchanged.

Timer and RxJS debounce: the synchronous advancets
afterEach(() => vi.useRealTimers());

it('debounces rapid terms down to the last one', () => {
  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']);
});
The microtask trap: a Promise in the chain needs the async advancets
// Sync advanceTimersByTime fires the timer but leaves the control PENDING,
// because it does not drain the microtask that writes the validation result.
it('flags a taken username after the async check', 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 and marble tests: no fakeAsync involvedts
// Effects flush during change detection: drive them with TestBed.tick().
TestBed.tick();
expect(service.history).toEqual(['idle']);

// Marble tests run on TestScheduler, independent of Zone.js, so they
// keep working unchanged. For timing-heavy streams this stays cleaner.
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 });
});

What I would actually do

The decision is per test, not per suite. A test you are already editing: rewrite it. It is a few lines, and it drops a Zone.js dependency you will not miss. A large suite nobody is touching: install the patch, keep CI green, and convert tests as features bring you back to them. A brand new test: write it with Vitest fake timers from the start and never meet the ProxyZone error at all.

This sits one step below the earlier question of whether to migrate the suite in the first place, covered in When to Move an Angular Test Suite to Vitest. And the patterns here are the same ones that surface when an app goes zoneless, because both remove the Zone that used to absorb timing for you.

Reusable artifact

fakeAsync to Vitest conversion checklist

  • Confirm the failure is the ProxyZone error, not a real assertion: the same spec passed under Karma.
  • Pick the path per test: patch a large untouched suite, rewrite the tests you actually touch.
  • For a rewrite, replace fakeAsync with vi.useFakeTimers() and call vi.useRealTimers() in afterEach.
  • If a Promise is anywhere in the chain (async validator, .then, whenStable), use await vi.advanceTimersByTimeAsync(): the synchronous advance leaves state pending.
  • For OnPush components, call detectChanges() after advancing time, since the timer marks the view dirty but does not render it.
  • Drive signal effects with TestBed.tick(), and leave marble tests on TestScheduler unchanged.
  • If you choose the patch, order the polyfills zone.js, zone.js/testing, zone.js/plugins/vitest-patch, and run on Vitest globals, not imported it.

FAQ

Why do my Angular tests throw "Expected to be running in 'ProxyZone'" after switching to Vitest?

Because fakeAsync, tick, flush, and waitForAsync need a Zone called ProxyZone that zone.js/testing installs around each spec, and the Vitest builder does not wrap your tests in it by default. The same specs passed under Karma because the Karma setup installed that zone. You fix it by adding the zone testing patch to your test polyfills, or by rewriting the tests to use Vitest fake timers.

Can I keep using fakeAsync under Vitest?

Yes, with the experimental patch. Set the test build polyfills to ["zone.js", "zone.js/testing", "zone.js/plugins/vitest-patch"] in that order, and make sure the suite uses Vitest global it and describe rather than imported ones, because the patch wraps the globals. Angular labels this experimental and recommends converting to native async and Vitest timers.

What replaces tick() and flush() in a Vitest test?

Use vi.useFakeTimers() with vi.advanceTimersByTime(ms) for synchronous timing, and await vi.advanceTimersByTimeAsync(ms) when a Promise is in the chain. Call vi.useRealTimers() in afterEach so timers do not leak between tests.

My async validator stays PENDING after I advance the timers. Why?

Because the synchronous vi.advanceTimersByTime fires the timer but does not drain the microtask that applies the validation result to the control. Use await vi.advanceTimersByTimeAsync(ms) instead, which flushes the timer and the microtask, so the control resolves to VALID or INVALID.

Do RxJS marble tests still work under Vitest?

Yes. TestScheduler does not depend on Zone.js or fakeAsync, so marble tests keep working unchanged. For timing-heavy streams they are often cleaner than fake timers.

Sources checked

Angular Vitest Migration Kit

Migrating this suite to Vitest?

The Angular Vitest Migration Kit sizes your migration by risk, then packages the config, the fakeAsync cookbook, an AI-agent skill, and a runnable demo. The conversions in this article, done for you.

Verified on Angular 21.2.14 / Vitest 4.1.8. Includes the migration estimator, the PDF guide, copy-paste config, an AI-agent skill, and a runnable demo.

Get the kit · US$15 →

André Ramosavailable for remote roles, UTC−3Get in touch →

Related

Guide · 6 min

When to Move an Angular Test Suite to Vitest

Vitest is the Angular CLI default, but migrating an existing suite is still experimental: which specs to pilot first, what fakeAsync breaks, and how to keep CI trustworthy.

Read article →

Guide · 10 min

The Code That Breaks When Angular Goes Zoneless

A code-heavy review guide for timers, subscriptions, forms, third-party callbacks, and tests before a zoneless Angular spike.

Read article →

Note · 6 min

What to Adopt, Spike, or Wait On in Angular 21

An adoption filter for Angular 21: what belongs in code already changing, what deserves a branch, and what should stay away from critical flows.

Read article →