---
title: "fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest"
description: "Why fakeAsync, tick, and waitForAsync throw the ProxyZone error under Angular's Vitest runner, and the two fixes: the experimental zone testing patch, or a rewrite to Vitest fake timers."
deck: "[Vitest is the default Angular test runner since v21](https://angular.dev/guide/testing), 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](https://angular.dev/guide/testing/migrating-to-vitest) is clear about which one it prefers."
author: "André Ramos"
url: "https://andreramos.dev/angular/angular-vitest-fakeasync-proxyzone-error/"
lang: "en"
type: "article"
---

# fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest

> [Vitest is the default Angular test runner since v21](https://angular.dev/guide/testing), 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](https://angular.dev/guide/testing/migrating-to-vitest) 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 throwing*

```ts
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.

*Same patch, two outcomes*

```ts
// 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 advance*

```ts
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 advance*

```ts
// 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 involved*

```ts
// 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](/angular/move-angular-test-suite-to-vitest). And the patterns here are the same ones that surface when an app goes [zoneless](/angular/code-that-breaks-when-angular-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

- 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

## Related

- [When to Move an Angular Test Suite to Vitest](https://andreramos.dev/angular/move-angular-test-suite-to-vitest/)
- [The Code That Breaks When Angular Goes Zoneless](https://andreramos.dev/angular/code-that-breaks-when-angular-goes-zoneless/)
- [What to Adopt, Spike, or Wait On in Angular 21](https://andreramos.dev/angular/adopt-spike-wait-angular-21/)
