javascript / expert
Snippet
Testing Zone-Aware Async Effects in Signal-Based Angular Components with fakeAsync
Signal-driven effects with setTimeout-based debouncing are notoriously hard to test because the effect's cleanup function must actually cancel the stale timer, not just let it run and get overwritten. This test uses fakeAsync and tick to deterministically control virtual time: it advances 100ms (before the debounce fires), changes the query again, then advances the remaining 300ms, and asserts only the final query's results survive — proving the onCleanup callback truly cleared the first setTimeout rather than merely racing it.
snippet.js
javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { Component, signal, effect, computed } from '@angular/core';import { TestBed, fakeAsync, tick } from '@angular/core/testing';@Component({ selector: 'app-debounced-search', template: '' })class DebouncedSearchComponent {readonly query = signal('');readonly results = signal<string[]>([]);private searchCallCount = 0;constructor() {effect((onCleanup) => {const term = this.query();const handle = setTimeout(() => {this.searchCallCount++;this.results.set(term ? [`${term}-1`, `${term}-2`] : []);}, 300);onCleanup(() => clearTimeout(handle));});}}describe('DebouncedSearchComponent', () => {it('cancels the pending search when the query changes before the debounce fires', fakeAsync(() => {const fixture = TestBed.createComponent(DebouncedSearchComponent);const component = fixture.componentInstance;fixture.detectChanges();component.query.set('ang');tick(100);component.query.set('angular');tick(300);fixture.detectChanges();expect(component.results()).toEqual(['angular-1', 'angular-2']);}));});
angular
Breakdown
1
effect((onCleanup) => {
The onCleanup callback parameter runs before the next effect execution or on destroy, making it the correct place to cancel in-flight async work.
2
onCleanup(() => clearTimeout(handle));
Registering cleanup inside the same effect run ties the timer's lifetime exactly to the signal read that created it.
3
it('cancels the pending search when the query changes before the debounce fires', fakeAsync(() => {
fakeAsync wraps the test in a zone that intercepts timers, letting tick() simulate time passing without real delays.
4
tick(100);
Advancing only 100ms of the 300ms debounce window proves the assertion isn't just waiting for both timers to eventually fire.
5
expect(component.results()).toEqual(['angular-1', 'angular-2']);
Asserting only the second query's results, with no trace of the first, confirms the stale timer was actually cancelled rather than both resolving.