In a zoneless Angular test suite on Vitest, what replaces fakeAsync and tick, and how do you test a 300 ms debounced search?
answer
- the runner owns the clock now
- renders are queued on timers too
- advance, then let Angular render
- restore real timers afterwards
basics
~20 sThe 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.
solid answer
~30 s`fakeAsync` needs zone.js and, per the testing guide, cannot be used with Vitest, so zoneless suites use the **runner's** fake timers: `vi.useFakeTimers()` before creating the fixture, `await vi.advanceTimersByTimeAsync(ms)` to move time, `vi.useRealTimers()` afterwards. One Angular-specific wrinkle: the zoneless scheduler queues each render with a `setTimeout`/`requestAnimationFrame` race, so with timers faked, a render does not happen until the clock moves — the guide calls `await vi.runAllTimersAsync()` right after `createComponent`. For the search, type, advance 299 ms and assert no request, advance 1 ms, flush the pending render, then assert the results.
code
ts · 8 linesbeforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('renders after the clock moves', async () => {
const fixture = TestBed.createComponent(ProductSearch);
await vi.runAllTimersAsync();
expect(fixture.nativeElement.querySelector('input')).not.toBeNull();
});go deeper
Know that zoneless Vitest tests use the runner's fake timers instead of fakeAsync, and that you advance time with the runner's API.
Explain that Angular's zoneless renders are queued on timers, why the guide flushes timers after createComponent, and how the old calls map to the new ones.
Write timing tests that advance time and renders deliberately, avoid leaked fake clocks, and migrate fakeAsync specs without losing boundary assertions.
Plan the conversion of a fakeAsync-heavy suite: which specs switch to runner timers, which to plain awaits, and how to keep timing contracts identical across the move.
## Why the tools change `fakeAsync`, `tick`, `flush`, `flushMicrotasks` and `discardPeriodicTasks` are built on zone.js. Their API docs carry the warning "This API requires Zone.js and cannot be used with the Vitest test runner", and the testing guide adds that `fakeAsync` "is no longer recommended". New Angular CLI projects are zoneless and use Vitest through the unit-test builder since v21, so their time-dependent tests use the **runner's fake timers** instead. The runner's timer API belongs to the runner, not to Angular; the Angular-specific part is how those timers interact with Angular's change detection scheduler. ## The Angular-specific wrinkle: renders are timers too In a zoneless app, a notification — a signal write read by a template, `setInput`, a template event, `markForCheck` — does not refresh the view synchronously. Angular's scheduler queues a render using a **race between `setTimeout` and `requestAnimationFrame`**, and records it as a pending task. With fake timers installed, that queued render waits for the fake clock like any other timer. Consequences: - Right after `TestBed.createComponent`, the first render will not happen until timers are advanced. The testing guide's Vitest example therefore calls `await vi.runAllTimersAsync()` after creating the component, with the comment that rendering needs to be flushed. - After your own timer fires (the debounce), the resulting render is **another** queued task; advance again before asserting on the DOM. - `fixture.whenStable()` waits for that pending render, so under fake timers it depends on the clock being moved. ## The debounced search, zoneless The component is the same product search as in a zone-based suite: a `FormControl` whose `valueChanges` go through `debounceTime(300)`, `distinctUntilChanged()` and `switchMap` to an API, exposed with `toSignal`. ```ts import {TestBed} from '@angular/core/testing'; import {of} from 'rxjs'; beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it('searches only after 300 ms of silence', async () => { const calls: string[] = []; TestBed.configureTestingModule({ providers: [{provide: ProductApi, useValue: {search: (q: string) => (calls.push(q), of([{id: 1, name: 'SSD 1TB'}]))}}], }); const fixture = TestBed.createComponent(ProductSearch); await vi.runAllTimersAsync(); // first render const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); input.value = 'ssd'; input.dispatchEvent(new Event('input')); await vi.advanceTimersByTimeAsync(299); expect(calls).toEqual([]); await vi.advanceTimersByTimeAsync(1); expect(calls).toEqual(['ssd']); await vi.runAllTimersAsync(); // the render queued by the new results expect(fixture.nativeElement.querySelectorAll('li').length).toBe(1); }); ``` ## Mapping the old calls | zone-based (`fakeAsync`) | Zoneless with runner fake timers | |---|---| | `fakeAsync(() => ...)` | `vi.useFakeTimers()` + an `async` test | | `tick(ms)` | `await vi.advanceTimersByTimeAsync(ms)` | | `flush()` | `await vi.runAllTimersAsync()` | | `flushMicrotasks()` | An `await`; promise callbacks run normally | | `fixture.detectChanges()` after time moves | Advance again so the queued render runs, or `await fixture.whenStable()` once timers can fire | | End-of-test cleanup | `vi.useRealTimers()` in `afterEach` | The `Async` variants matter: they let promise callbacks run between timers, which Angular's scheduler and RxJS pipelines rely on. ## Why the async advance calls Runners usually offer synchronous and asynchronous ways to move time. The asynchronous ones let promise callbacks that a timer sets off run before the next timer fires. That matters whenever time-based code hands work on through promises, for example: - an API stub that returns a `Promise` resolved after a delay; - a `resource()` loader, which is an `async` function by design; - the custom wait function of `debounced()`, which returns a `Promise<void>`. A synchronous advance can fire the timers and still leave those follow-ups unrun, so the test asserts on a half-finished state. Using the async variants everywhere costs nothing and removes that class of flake. ## Pitfalls 1. **Installing fake timers after creating the fixture.** The first render may already be queued on real timers; install them first, as the guide does. 2. **Forgetting to restore real timers.** Later tests inherit the fake clock and hang or behave oddly. 3. **Asserting on the DOM right after the debounce fires.** The data changed; the render is still queued. 4. **Mixing styles.** A test that also loads zone.js and calls `fakeAsync` is not using this model at all. ## Newer option: debounced() Angular 22 added an **experimental** `debounced(source, ms)` that returns a resource whose `value()` settles after the wait. It uses `setTimeout` internally, so the same approach applies: write the source signal, advance the fake clock past the wait, then assert the resource's value and status.
- Why does the Angular testing guide call runAllTimersAsync() right after createComponent in its Vitest example?In zoneless mode the first render is queued by Angular's scheduler on a setTimeout/requestAnimationFrame race. With fake timers installed, that queued callback waits for the fake clock, so nothing renders until the test advances it. Running the pending timers lets the render happen before the test queries the DOM.
- How would you test a component that uses the experimental debounced() signal with a 300 ms wait?`debounced` returns a resource and waits with `setTimeout`, so install fake timers, write the source signal, and advance the clock past 300 ms. While the timer runs the resource's `status()` is 'loading' and `value()` holds the previous value; after the advance it settles to 'resolved' with the new value. Advance again so the resulting render runs before checking the DOM.
saying these in an interview costs you the question
- fakeAsync works on Vitest once zone.js/testing is imported.
- Zoneless Angular renders synchronously, so fake timers never affect rendering.
- One advance past the debounce is enough before asserting on the DOM.
- Synchronous timer advances are fine for promise-based pipelines.
- Fake timers can be installed after createComponent without consequences.