Modern Angular
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.
// 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 did | Vitest equivalent | When |
|---|---|---|
tick(ms) | vi.advanceTimersByTime(ms) | Timers only, nothing returns a Promise |
tick(ms) with a Promise in the chain | await 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 tests | vi.useRealTimers() in afterEach | Always |
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.
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().
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
asyncandawait; skip fake timers. - Always:
vi.useRealTimers()inafterEach.
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
- https://angular.dev/guide/testing
- https://angular.dev/guide/testing/migrating-to-vitest
- 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 →