Modern Angular
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.
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.
| Path | What you change | Best when | Cost |
|---|---|---|---|
| Zone testing patch | Add the zone patch to the test polyfills and keep fakeAsync/tick | A large suite you cannot rewrite in one branch | Experimental, with two sharp edges |
| Rewrite to Vitest timers | Replace fakeAsync with vi.useFakeTimers() and advance time yourself | Tests you already touch, and every new test | A 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.
// 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.
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']);
});// 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 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
fakeAsyncwithvi.useFakeTimers()and callvi.useRealTimers()inafterEach. - If a Promise is anywhere in the chain (async validator,
.then,whenStable), useawait 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 onTestSchedulerunchanged. - If you choose the patch, order the polyfills
zone.js,zone.js/testing,zone.js/plugins/vitest-patch, and run on Vitest globals, not importedit.
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
- 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
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.
André Ramosavailable for remote roles, UTC−3Get in touch →