Replacing fakeAsync and tick when Angular tests move to Vitest

ng new installed Angular 22.2.0 on my machine this week, and the project it wrote runs its unit tests on Vitest 5.0.2 through the @angular/build:unit-test builder. Vitest has been the CLI's default for new projects since v21. The other half of that change matters more for old tests: the generated app has no zone.js in it at all. There is none in package.json, none in the build target's polyfills, and globalThis.Zone is undefined when the suite runs. (Zoneless change detection got a post of its own; this one is only about the tests.)
fakeAsync, tick and flush are exported from @angular/core/testing, which makes them look like framework functions with no dependencies. They are implemented by zone.js/testing. So a test that has worked since Karma and Jasmine now does this.
The old test, on the new setup
Here is the component under test, a search box that waits 300 ms after the last keystroke before it asks anything:
export const DEBOUNCE_MS = 300;
@Component({
selector: 'app-search-box',
imports: [ReactiveFormsModule],
template: `
<input id="q" type="search" [formControl]="query" />
<p data-testid="requests">requests: {{ requests() }}</p>
<ul data-testid="results">
@for (row of results(); track row) { <li>{{ row }}</li> }
</ul>
`,
})
export class SearchBox {
private readonly gateway = inject(SearchGateway);
readonly query = new FormControl('', { nonNullable: true });
readonly results = signal<readonly string[]>([]);
readonly requests = signal(0);
constructor() {
this.query.valueChanges
.pipe(
debounceTime(DEBOUNCE_MS),
distinctUntilChanged(),
tap(() => this.requests.update((n) => n + 1)),
switchMap((term) => this.gateway.search(term)),
takeUntilDestroyed(),
)
.subscribe((rows) => this.results.set(rows));
}
}
And the test as it was written:
it('sends one request for a burst of keystrokes', fakeAsync(() => {
const fixture = TestBed.createComponent(SearchBox);
const box = fixture.componentInstance;
box.query.setValue('a');
tick(100);
box.query.setValue('an');
tick(100);
box.query.setValue('ang');
tick(299);
expect(search).not.toHaveBeenCalled();
tick(1);
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith('ang');
flush();
fixture.destroy();
}));
ng test with that file included (ng test -c fixture-default in the demo):
⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL angular-vitest-fake-timers-demo src/fixtures/search-box-fakeasync.spec.ts ...
Error: zone-testing.js is needed for the fakeAsync() test helper but could not be found.
Please make sure that your environment includes zone.js/testing
❯ withFakeAsyncTestModule .../packages/core/testing/src/fake_async.ts:20:11
❯ fakeAsync .../packages/core/testing/src/fake_async.ts:71:10
❯ src/fixtures/search-box-fakeasync.spec.ts:28:53
Test Files 1 failed (1)
Tests no tests
Note the wording: Failed Suites, and a test count of no tests. The reason is where the throw happens: fakeAsync(fn) throws while it's building the wrapper, not while the wrapper runs. it('...', fakeAsync(...)) evaluates that call during collection, so the file never produces any tests, and a second, unrelated test sitting in the same file never runs either. tick() and flush() throw the same message wherever you call them, since they delegate to the same missing zone.js/testing.
The same test on Vitest's clock
vi.useFakeTimers() replaces the global timer functions with a clock the test winds by hand, which is the same idea tick was built on. debounceTime schedules its wait through RxJS's async scheduler, and that scheduler calls setInterval, so faked setInterval and clearInterval are enough to drive it.
beforeEach(async () => {
vi.useFakeTimers();
search = vi.fn((term: string) => of([`${term} (1)`, `${term} (2)`]));
TestBed.configureTestingModule({
providers: [{ provide: SearchGateway, useValue: { search } }],
});
fixture = TestBed.createComponent(SearchBox);
await vi.advanceTimersByTimeAsync(1); // let the first render happen
});
afterEach(() => {
vi.useRealTimers();
});
it('collapses a burst of keystrokes into one request', async () => {
type('a');
await vi.advanceTimersByTimeAsync(100);
type('an');
await vi.advanceTimersByTimeAsync(100);
type('ang');
await vi.advanceTimersByTimeAsync(100);
expect(search).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
expect(search).toHaveBeenCalledExactlyOnceWith('ang');
});
Structurally it's the old test with tick(n) swapped for await vi.advanceTimersByTimeAsync(n), and that is most of the migration. The three keystrokes land 100 ms apart, each one restarts the debounce, and 300 ms after the last one a single call goes out with the final term.
What the fake clock actually covers
Vitest fakes a fixed list of globals, and it's worth knowing which, because two of them decide how the rest of this post goes. On Vitest 5.0.2 under jsdom 30.1.1 and Node 26.1.0, vi.useFakeTimers() with no options replaces twelve:
setTimeout clearTimeout
setInterval clearInterval
setImmediate clearImmediate
requestAnimationFrame cancelAnimationFrame
Date performance
Intl Temporal
queueMicrotask and process.nextTick stay real. requestIdleCallback isn't faked either, though only because jsdom doesn't provide it in the first place. The two that matter here are requestAnimationFrame and Date. Faking Date lets a test assert that exactly 300 ms went by. Faking requestAnimationFrame is what made my first rewrite hang.
fixture.whenStable() waits on the same clock
My first rewrite passed its spy assertions and then hung for five seconds on a DOM assertion. This is the part I would not have guessed.
Zoneless change detection has to schedule its own pass, and it does that in scheduleCallbackWithRafRace: one setTimeout(callback) racing one requestAnimationFrame(callback), whichever fires first, with the loser cancelled. Both of those are on Vitest's faked list. A fake clock that nobody advances therefore has a fixture.whenStable() that never resolves. In the demo I race that promise against a real 60 ms timeout grabbed before the clock was faked; the timeout wins every time.
A test that never finishes is at least easy to spot. Stopping the clock exactly on the debounce boundary is worse, because the test looks finished and isn't:
fixture.componentInstance.query.setValue('ang');
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
expect(fixture.componentInstance.requests()).toBe(1); // the request went out
expect(vi.getTimerCount()).toBe(2); // the render is queued
let settled = false;
void fixture.whenStable().then(() => (settled = true));
await vi.advanceTimersByTimeAsync(0);
expect(settled).toBe(false); // a zero-length advance changes nothing
await vi.advanceTimersByTimeAsync(1);
expect(settled).toBe(true); // one millisecond is enough
Two pending timers, which is the setTimeout and the rAF of that race, both scheduled at the instant the advance ended. One more millisecond of clock runs them. So the order that works is: advance past the thing you're waiting for, not up to it, and only then await the fixture.
type('ang');
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 1);
await fixture.whenStable();
expect(requestsText()).toBe('requests: 1');
await vi.runAllTimersAsync() also works and avoids the off-by-one entirely, which I prefer when the test doesn't care about the exact interval. It runs everything queued, including timers queued by those timers, so it's the wrong tool on a component with a repeating setInterval. And fixture.detectChanges() is still synchronous and still needs no clock at all, so a test that only wants the DOM updated once can call that and skip the whole question.
The synchronous advance stops short of a promise
vi.advanceTimersByTime(n) and vi.advanceTimersByTimeAsync(n) look interchangeable. The synchronous one runs the timers due in the window and returns. The async one awaits between them, which lets the microtask queue drain as the clock moves, and that difference shows up the moment your gateway answers with a promise instead of of(...).
fixture.componentInstance.query.setValue('ang');
vi.advanceTimersByTime(DEBOUNCE_MS);
expect(search).toHaveBeenCalledExactlyOnceWith('ang'); // the call went out
expect(fixture.componentInstance.results()).toEqual([]); // the answer did not arrive
The spy assertion passes, so a test written that way looks green while asserting nothing about the result. Swap in await vi.advanceTimersByTimeAsync(DEBOUNCE_MS) and results() holds ['ang (1)']. I've settled on using the async form everywhere and only reaching for the synchronous one when there's a reason.
The other thing to be strict about is putting the clock back. vi.useRealTimers() in afterEach matters more than it looks: a fake clock installed in one test is still installed in the next test in the same file, and the failure lands in whichever test comes after, which is a miserable thing to debug.
Keeping fakeAsync when you have a suite too big to rewrite
fakeAsync still works on Vitest once you give it its zone back. Angular ships a patch that wraps Vitest's describe and it in zones, with a ProxyZone around each test body, which is what the helpers look for, and the migration guide tells you to add zone.js/plugins/vitest-patch to the test target's polyfills. On a project migrating from Karma that's genuinely one line, because the polyfills list already has the other two entries. Starting from a project the v22 CLI generated, it has none of them, and all three are needed, with zone.js first (listing zone.js/testing on its own gives ReferenceError: Zone is not defined):
"polyfills": ["zone.js", "zone.js/testing", "zone.js/plugins/vitest-patch"]
Leave out the patch and you get a different error, raised by zone.js and not by Angular, because Zone exists but the test isn't running inside a ProxyZone:
Error: Expected to be running in 'ProxyZone', but it was not found.
❯ _ProxyZoneSpec.assertPresent node_modules/zone.js/fesm2015/zone-testing.js:883:13
❯ fakeAsyncFn node_modules/zone.js/fesm2015/zone-testing.js:1648:42
With all three in place the original test passes unchanged, ticks and flush and all. The demo runs that one file under four polyfill lists, from none to all three, and each of those runs is one ng test -c away.
One wrinkle if you go this way: the @angular/build:unit-test builder has no polyfills option of its own, so the list has to live on a build configuration that the test target points at with buildTarget. I would still treat it as temporary. Angular's own migration guide says to plan on converting the suites to native async and Vitest fake timers, and the tests left on tick() will need the rewrite eventually.
The demo is at github.com/gdhami-net/angular-vitest-fake-timers-demo. npm run check runs all five test configurations and checks the exit code and the message of each against the table in its README. Versions: Angular 22.2.0, Vitest 5.0.2, @angular/build 22.2.0, TypeScript 6.0.3, zone.js 0.16.3, jsdom 30.1.1, Node 26.1.0. I haven't repeated any of it on Linux or against a real browser.