Vitest Fake Timers in Angular: The Mental Model After fakeAsync

fakeAsync did two jobs at once: it advanced virtual time and it drained Promise microtasks. Vitest splits them: vi.useFakeTimers() controls time, and you await the microtasks yourself. Almost every Angular timing test under Vitest is that one idea, applied.

fakeAsync was doing two jobs

fakeAsync fused two responsibilities into one helper. tick(ms) fast-forwarded timers, and it also flushed pending Promise callbacks. That fusion is why a test waiting on a .then() inside a setTimeout just worked, and it is why moving to Vitest feels like a step back at first: Vitest does not fuse them.

Vitest gives you the two halves separately. Virtual time is vi.useFakeTimers() plus vi.advanceTimersByTime(ms). The microtask half is your own await. Hold that split in your head and the rest of the migration is mechanical: wherever fakeAsync did both for free, you now do them in two moves.

The same test, before and afterts
// fakeAsync did both jobs in one call:
it('polls three times', fakeAsync(() => {
  const service = TestBed.inject(PollService);
  service.start();
  tick(3000);
  expect(service.count()).toBe(3);
}));

// Vitest: time is explicit, and there is no Promise here, so no await.
it('polls three times', () => {
  vi.useFakeTimers();
  const service = TestBed.inject(PollService);
  service.start();
  vi.advanceTimersByTime(3000);
  expect(service.count()).toBe(3);
});

The API, mapped

Most of the conversion is a lookup. The one judgment call is whether a Promise is in the chain, which decides between the synchronous advance and the async one.

What fakeAsync didVitest equivalentWhen
tick(ms)vi.advanceTimersByTime(ms)Timers only, nothing returns a Promise
tick(ms) with a Promise in the chainawait vi.advanceTimersByTimeAsync(ms)Async validator, .then, whenStable
flush()vi.runAllTimers() / await vi.runAllTimersAsync()Drain every pending timer at once
discardPeriodicTasks()vi.clearAllTimers()Stop intervals before asserting
reset between testsvi.useRealTimers() in afterEachAlways

The single rule that catches everyone

vi.advanceTimersByTime() fires timers. It does not drain Promise microtasks. So the moment a Promise enters the chain (an async validator, a .then, a whenStable), the synchronous advance fires the timer but your assertion runs before the callback resolves. The control is still PENDING, the value is still undefined, and the failure looks impossible.

The fix is one substitution: await vi.advanceTimersByTimeAsync(ms), which advances time and drains microtasks. The rule of thumb is small enough to memorize: any Promise in the chain, use the ...Async variant and await it. That single rule is most of what makes Vitest timing tests feel different from fakeAsync.

A Promise in the chain needs the async advancets
it('flags a taken username', async () => {
  vi.useFakeTimers();
  const control = new FormControl('taken', {
    asyncValidators: [uniqueUsernameValidator(['taken'])],
  });

  await vi.advanceTimersByTimeAsync(200);   // time AND microtasks

  expect(control.status).toBe('INVALID');
});

The parts that are not timers

Three Angular things look like timing but are not, so reaching for advanceTimersByTime does nothing useful.

RxJS schedulers do ride the timer. debounceTime, throttleTime, and the async scheduler all run on the same setInterval that vi controls, so advancing time fires them with no special handling. That part is easy.

Signal effects do not. An effect flushes during change detection, not on a clock, so advancing time never runs it. Drive it with TestBed.tick() (TestBed.flushEffects() is deprecated). And change detection itself still has to run: advancing time can update a signal, but an OnPush component only renders after detectChanges().

An effect is flushed by change detection, not a timerts
const service = TestBed.inject(ActivityLogService);

TestBed.tick();                 // flush the effect, no timer involved
service.status.set('busy');
TestBed.tick();
expect(service.history()).toEqual(['idle', 'busy']);

When not to reach for fake timers

Fake timers are not always the right tool. For a timing-heavy stream, a marble test models the timeline directly and never touches Zone.js or fake timers: cold('a-b-c-------|') through debounceTime(3) asserts '-------c----|' on TestScheduler. For several emissions over time, that is usually clearer than advancing a clock by hand.

And if nothing schedules a timer, you do not need fake timers at all. A plain Promise test just needs to be async and to await the value. Fake timers earn their place only when there is virtual time to control.

That is the whole model: control time with vi.useFakeTimers(), drain microtasks with await, flush effects with TestBed.tick(), and let marble tests own the timing-heavy streams. The ProxyZone error and its rewrites are this model applied to a broken test; whether to migrate a suite at all is the decision above it.

Reusable artifact

Which timer tool for which test

  • Only timers, nothing returns a Promise: vi.advanceTimersByTime(ms).
  • A Promise anywhere in the chain (async validator, .then, whenStable): await vi.advanceTimersByTimeAsync(ms).
  • A signal effect: TestBed.tick(), not a timer.
  • An OnPush component: call detectChanges() after advancing time so it renders.
  • A timing-heavy RxJS stream: a marble test on TestScheduler, no fake timers.
  • A plain Promise with no timer: make the test async and await; skip fake timers.
  • Always: vi.useRealTimers() in afterEach.

FAQ

What is the difference between vi.advanceTimersByTime and vi.advanceTimersByTimeAsync?

The synchronous vi.advanceTimersByTime(ms) fires timers only. The async vi.advanceTimersByTimeAsync(ms) also drains Promise microtasks between them. Use the async variant whenever a Promise is in the chain (an async validator, a .then, a whenStable), or your assertion runs before the result is applied.

Why did fakeAsync's tick() handle both timers and Promises?

Because fakeAsync fused two jobs: advancing virtual time and flushing pending microtasks. tick(ms) did both. Vitest separates them, so under the new runner you advance time and drain microtasks in two moves, the second one being an await.

How do I flush a signal effect with Vitest fake timers?

You do not use timers for effects. A signal effect flushes during change detection, not on a clock, so advancing time does nothing for it. Call TestBed.tick() instead (TestBed.flushEffects() exists but is deprecated).

Do I need fake timers for an RxJS marble test?

No. TestScheduler models the timeline itself and does not depend on Zone.js or fake timers, so marble tests keep working unchanged. For streams with several emissions over time they are often clearer than advancing a clock by hand.

When should I avoid fake timers entirely?

When nothing schedules a timer. If a test only waits on a Promise with no setTimeout or interval underneath, make it async and await the value. Fake timers are worth it only when there is virtual time to control.

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 · 9 min

fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest

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.

Read article →

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 →