skip to content

Fake Clocks & Async Waits

fakeAsync with tick, flush and flushMicrotasks moves a virtual clock inside a zone, waitForAsync waits for real tasks and TestBed.tick runs effects. Interviewers ask what replaces them when zoneless.

part ofAngularoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In a zone-based Angular test, how do you use fakeAsync and tick to prove a search input waits 300 ms after typing stops?

level: middleimportance: must knowfreq 55%

answer

  1. type, then advance the clock
  2. check just before the deadline
  3. one more millisecond fires it
  4. refresh the fixture before asserting

basics

~10 s

Wrap the test in fakeAsync, type with dispatchEvent, tick to just under 300 ms and assert nothing was searched, then tick the last millisecond, call detectChanges and assert the request and the rendered results.

solid answer

~40 s

Inside `fakeAsync`, `debounceTime(300)` schedules its timer on the fake clock. Create the fixture and `detectChanges()` so the form control is wired, set the input's `value` and dispatch an `input` event, then `tick(299)` and assert the fake API has not been called. `tick(1)` reaches 300 ms: the debounce emits, `switchMap` calls the fake API, and `toSignal` updates. Call `fixture.detectChanges()` — zone-based fixtures do not auto-detect by default — and assert the list. To prove the debounce **resets**, type twice within the window and check that only the last value is searched. This needs zone.js; on Vitest or in zoneless suites you use the runner's fake timers instead.

code

ts · 11 lines
ts
const calls: string[] = [];
const fakeApi = {
  search: (q: string) => {
    calls.push(q);
    return of([{id: 1, name: 'SSD 1TB'}]);
  },
};

TestBed.configureTestingModule({
  providers: [{provide: ProductApi, useValue: fakeApi}],
});

go deeper

for a junior

Know the shape: fakeAsync around the test, dispatch an input event, tick past the debounce, detectChanges, then assert.

for a middle

Explain why asserting at 299 ms matters, how the reset is proved, why dispatchEvent is needed and why the zone-based fixture needs detectChanges.

for a senior

Cover cancellation and duplicate suppression, know where fakeAsync stops applying, and keep time-based tests fast and exact.

for a principal

Decide how the team tests time-dependent UI across zone-based and zoneless suites so both styles assert the same timing contract.

## The component A product search waits for the user to stop typing before calling the API: ```ts import {Component, inject} from '@angular/core'; import {toSignal} from '@angular/core/rxjs-interop'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {debounceTime, distinctUntilChanged, switchMap} from 'rxjs'; import {ProductApi} from './product-api'; @Component({ selector: 'app-product-search', imports: [ReactiveFormsModule], template: ` <input [formControl]="query" /> <ul> @for (p of results(); track p.id) { <li>{{ p.name }}</li> } </ul> `, }) export class ProductSearch { private readonly api = inject(ProductApi); readonly query = new FormControl('', {nonNullable: true}); readonly results = toSignal( this.query.valueChanges.pipe( debounceTime(300), distinctUntilChanged(), switchMap((q) => this.api.search(q)), ), {initialValue: []}, ); } ``` `debounceTime(300)` delays each value by 300 ms and restarts the delay whenever a new value arrives, so only the last value in a burst reaches `switchMap`. ## What the test must prove 1. Nothing is searched while the user is still typing, or before 300 ms of silence. 2. Exactly the last value is searched once 300 ms have passed. 3. The results render. Real timers would make this slow and flaky. `fakeAsync` makes it instant and exact. ## The test ```ts import {TestBed, fakeAsync, tick} from '@angular/core/testing'; import {of} from 'rxjs'; it('searches only after 300 ms of silence', fakeAsync(() => { const calls: string[] = []; const fakeApi = { search: (q: string) => { calls.push(q); return of([{id: 1, name: 'SSD 1TB'}]); }, }; TestBed.configureTestingModule({providers: [{provide: ProductApi, useValue: fakeApi}]}); const fixture = TestBed.createComponent(ProductSearch); fixture.detectChanges(); const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); input.value = 'ss'; input.dispatchEvent(new Event('input')); tick(200); input.value = 'ssd'; input.dispatchEvent(new Event('input')); // restarts the 300 ms window tick(299); expect(calls).toEqual([]); tick(1); fixture.detectChanges(); expect(calls).toEqual(['ssd']); expect(fixture.nativeElement.querySelectorAll('li').length).toBe(1); })); ``` ## Step by step - **`fakeAsync`** installs the fake clock. `debounceTime` uses RxJS's `asyncScheduler`, which schedules with `setInterval` and reads `Date.now()`; both are patched inside the fake zone, so the debounce runs on virtual time. - **`fixture.detectChanges()`** renders once so the `[formControl]` directive is attached to the input. In a zone-based test the fixture does not auto-detect unless you enable it. - **`dispatchEvent(new Event('input'))`** is how Angular's form directive learns the new value; setting `value` alone does nothing. - **`tick(200)` then a second value** proves the reset: the first value never reaches the API. - **`tick(299)`** stops one millisecond short of the deadline measured from the second keystroke. Asserting `calls` is still empty proves the delay is at least 300 ms. - **`tick(1)`** crosses the deadline: the debounce emits `'ssd'`, `switchMap` calls the fake API, the synchronous `of([...])` delivers results, and `toSignal`'s signal updates. - **`fixture.detectChanges()`** again renders the new list, since the view reads `results()`. ## Why tick and not flush here `tick(ms)` lets you assert **between** points in time — the whole point of a debounce test. `flush()` jumps to "no timers left" and returns how much time passed, which cannot show that nothing happened at 299 ms. There is a second reason, specific to RxJS: `flush()` stops when only **periodic** timers remain, and the async scheduler behind `debounceTime` uses `setInterval`, so `flush()` may leave the debounce unfired. ## Variations interviewers ask for | Variation | What to change | |---|---| | Duplicate query is ignored | Type `'ssd'` again after the search; `distinctUntilChanged` means `calls` stays `['ssd']` | | Slow API | Return `timer(500).pipe(map(() => [...]))` and `tick(500)` more before asserting | | Superseded request is cancelled | Type again while the slow API is pending; assert only the latest results render | | Empty query | Decide the rule (search or skip) and assert it explicitly | ## Common failures and what they mean - **`calls` is empty after `tick(1)`.** The input event never reached the form control: the fixture was not rendered before typing, or `value` was set without dispatching `input`. - **`calls` holds both values.** The debounce is not in the pipeline, or the two keystrokes were more than 300 ms apart in virtual time. - **The list is empty although `calls` is right.** Change detection did not run after the results arrived; call `detectChanges()`. - **An error about `zone.js/testing` being needed.** The suite runs without zone.js; the zone-free approach is required. ## Limits of this approach - It needs `zone.js` and `zone.js/testing`, and a runner that zone.js patches. The testing guide calls `fakeAsync` no longer recommended and states it cannot be used with Vitest. - In a zoneless suite the same test is written with the runner's fake timers, advancing time with the runner's API and awaiting the scheduled render.

  • How would you prove that a slow request for an old query is discarded when the user types again?
    Make the fake API slow, for example `timer(500).pipe(map(() => results))`. Type 'ss', `tick(300)` so its request starts, type 'ssd' and `tick(300)` again, then `tick(500)`. With `switchMap`, the first inner request is unsubscribed when 'ssd' arrives, so after `detectChanges()` only the 'ssd' results render.
  • Why is detectChanges() needed after tick(1) in this zone-based test?
    In a zone-based test the fixture does not auto-detect unless you call `autoDetectChanges()` or provide `ComponentFixtureAutoDetect`. The debounce updated the `results` signal, which marks the view for refresh, but something still has to run change detection; `detectChanges()` does it synchronously inside the fake zone.

saying these in an interview costs you the question

  • tick(300) once is enough; asserting at 299 ms adds nothing.
  • flush() is the best way to fire a pending RxJS debounceTime.
  • Setting the input's value property is enough for the form control to see it.
  • The debounce restarts only if the second value differs from the first.
  • This fakeAsync test runs unchanged on the Vitest runner.
open as a page

In a zoneless Angular test suite on Vitest, what replaces fakeAsync and tick, and how do you test a 300 ms debounced search?

level: middleimportance: must knowfreq 50%

basics

~20 s

The runner's fake timers replace fakeAsync: install them, advance virtual time with the runner's async advance calls, and remember that Angular's zoneless scheduler also queues renders on timers, so advance until the render runs before asserting.

open as a page

In Angular testing, what is the difference between waitForAsync and fakeAsync, and when would a test use each?

level: juniorimportance: should knowfreq 55%

basics

~10 s

waitForAsync lets real time pass and ends the test when every async task it started has finished; fakeAsync runs the test on a virtual clock you advance with tick or flush. Both need zone.js.

open as a page

In an Angular test, why doesn't an effect() run right after a signal write, and what does TestBed.tick() do about it?

level: middleimportance: should knowfreq 36%

basics

~20 s

Angular effects run asynchronously as part of change detection, never inside the signal write. TestBed.tick() runs that pending work synchronously, flushing root effects and refreshing test views, so the test can assert the effect's result right away.

open as a page

In an Angular fakeAsync test, why can flush() leave an RxJS debounceTime unfired, and what do discardPeriodicTasks and the flush option change?

level: seniorimportance: should knowfreq 30%

basics

~20 s

RxJS's async scheduler, used by debounceTime, schedules with setInterval, and flush() stops once only periodic timers remain, so the debounce may never fire. Use tick(ms). discardPeriodicTasks drops leftover intervals; fakeAsync's default flush option flushes timers at the end.

open as a page