Skip to the solution
HardFrontend9 min read

What is the difference between fakeAsync, tick, and waitForAsync in Angular unit tests?

224 views

The problem

Question presented to candidate: "A component shows a toast 500 ms after a click. Explain how you would test it with fakeAsync and tick, how waitForAsync differs, and what changes in a new Angular 22 project that runs Vitest without Zone.js."

What a strong answer should cover:

  • fakeAsync runs the test in a fake async zone: timers do not really wait, and the test body stays synchronous.
  • tick(ms) advances virtual time and runs due timers; flush() drains all timers and returns the virtual ms skipped; flushMicrotasks() drains promises only.
  • waitForAsync runs the test in an async test zone with real time and finishes the test only when every task in the zone has completed.
  • Both are Zone.js features: without zone.js/testing they throw or reject; under Vitest they also need zone.js/plugins/vitest-patch to create the ProxyZone.
  • In a zoneless Angular 22 project the idiomatic replacements are vi.useFakeTimers() + vi.advanceTimersByTime() + fixture.detectChanges(), or plain async/await with fixture.whenStable().
  • fakeAsync throws at the end if timers are still pending only when { flush: false } is passed; by default it flushes them.

Clarifying questions expected:

  • "Does the project load Zone.js, and which runner: Vitest or Karma/Jasmine?"
  • "Is the delay a timer, a promise, or an HTTP call?"

Code / implementation expected: Yes β€” a Zone-based spec using fakeAsync/tick/flush/waitForAsync, and the zoneless equivalent with Vitest fake timers.

  • testing
  • async

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 9 sections

Read the code

AngularZone.js workspace: fakeAsync, tick, flush, flushMicrotasks and waitForAsync under Vitest (zone.js 0.16.3 + vitest-patch)
// Needs zone.js + zone.js/testing in the build polyfills and
// zone.js/plugins/vitest-patch as a Vitest setup file (see the answer).
import { Component, signal } from '@angular/core';
import { TestBed, fakeAsync, flush, flushMicrotasks, tick, waitForAsync } from '@angular/core/testing';

@Component({
  selector: 'app-toast',
  template: `<button (click)="save()">Save</button><p>{{ status() }}</p>`,
})
class Toast {
  status = signal('idle');
  save() {
    this.status.set('saving');
    setTimeout(() => this.status.set('saved'), 500);
  }
}

const p = (f: { nativeElement: HTMLElement }) => f.nativeElement.querySelector('p')!.textContent;

describe('fakeAsync / tick / waitForAsync (Zone.js loaded)', () => {
  it('tick() moves virtual time forward', fakeAsync(() => {
    const fixture = TestBed.createComponent(Toast);
    fixture.detectChanges();
    fixture.nativeElement.querySelector('button').click();
    fixture.detectChanges();
    expect(p(fixture)).toBe('saving');

    tick(499); // 499 virtual ms, returns instantly
    fixture.detectChanges();
    expect(p(fixture)).toBe('saving');

    tick(1);
    fixture.detectChanges();
    expect(p(fixture)).toBe('saved');
  }));

  it('flush() runs every pending timer and returns the virtual time it skipped', fakeAsync(() => {
    const fixture = TestBed.createComponent(Toast);
    fixture.componentInstance.save();
    expect(flush()).toBe(500);
    expect(fixture.componentInstance.status()).toBe('saved');
  }));

  it('flushMicrotasks() drains promises but not timers', fakeAsync(() => {
    const order: string[] = [];
    Promise.resolve().then(() => order.push('microtask'));
    setTimeout(() => order.push('timer'), 0);
    flushMicrotasks();
    expect(order).toEqual(['microtask']);
    tick();
    expect(order).toEqual(['microtask', 'timer']);
  }));

  let fired = false;
  it('waitForAsync() keeps the test open until the zone has no pending tasks', waitForAsync(() => {
    setTimeout(() => (fired = true), 150); // a REAL 150 ms wait, no done() or await needed
  }));
  it('...so the timer had fired before the next test started', () => {
    expect(fired).toBe(true);
  });

  it('a timer left pending: fine by default, an error with { flush: false }', () => {
    expect(() => fakeAsync(() => setTimeout(() => {}, 1000))()).not.toThrow();
    expect(() => fakeAsync(() => setTimeout(() => {}, 1000), { flush: false })()).toThrow(
      '1 timer(s) still in the queue.',
    );
  });
});
AngularDefault zoneless workspace: what the Zone helpers do, and the Vitest fake-timer replacement
import { Component, signal } from '@angular/core';
import { TestBed, fakeAsync, waitForAsync } from '@angular/core/testing';

@Component({
  selector: 'app-toast',
  template: `<button (click)="save()">Save</button><p>{{ status() }}</p>`,
})
class Toast {
  status = signal('idle');
  save() {
    this.status.set('saving');
    setTimeout(() => this.status.set('saved'), 500);
  }
}

const p = (f: { nativeElement: HTMLElement }) => f.nativeElement.querySelector('p')!.textContent;

describe('timers in the default CLI setup (Vitest, no zone.js)', () => {
  it('fakeAsync and waitForAsync need zone.js', async () => {
    expect(() => fakeAsync(() => {})()).toThrow(
      'zone-testing.js is needed for the fakeAsync() test helper but could not be found.',
    );
    await expect((waitForAsync(() => {}) as any)()).rejects.toMatch('Zone is needed for the waitForAsync() test helper');
  });

  describe('Vitest fake timers instead', () => {
    beforeEach(() => vi.useFakeTimers());
    afterEach(() => vi.useRealTimers());

    it('advance virtual time, render with detectChanges()', () => {
      const fixture = TestBed.createComponent(Toast);
      fixture.detectChanges();
      fixture.nativeElement.querySelector('button').click();
      fixture.detectChanges();
      expect(p(fixture)).toBe('saving');

      vi.advanceTimersByTime(499);
      fixture.detectChanges();
      expect(p(fixture)).toBe('saving');

      vi.advanceTimersByTime(1);
      fixture.detectChanges();
      expect(p(fixture)).toBe('saved');
    });

    it('or let the scheduler render: advanceTimersByTimeAsync also runs its timer', async () => {
      const fixture = TestBed.createComponent(Toast);
      await vi.advanceTimersByTimeAsync(0);
      expect(p(fixture)).toBe('idle');
      fixture.nativeElement.querySelector('button').click();
      await vi.advanceTimersByTimeAsync(500); // fires save()'s timer
      expect(p(fixture)).toBe('saving'); // the render it triggered is still queued
      await vi.advanceTimersByTimeAsync(20); // one more frame
      expect(p(fixture)).toBe('saved');
    });
  });

  it('or plain async/await with real time (simple, but the test really waits)', async () => {
    const fixture = TestBed.createComponent(Toast);
    await fixture.whenStable();
    fixture.nativeElement.querySelector('button').click();
    await new Promise((r) => setTimeout(r, 520));
    await fixture.whenStable();
    expect(p(fixture)).toBe('saved');
  });
});

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 117 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextHard

How does Hydration work in modern Angular SSR, and what is non-destructive hydration?

Continue