javascript / expert
Snippet
Testen zonenbewusster asynchroner Effekte in signalbasierten Angular-Komponenten mit fakeAsync
Signalgetriebene Effekte mit setTimeout-basiertem Debouncing sind notorisch schwer zu testen, weil die Cleanup-Funktion des Effekts den veralteten Timer tatsächlich abbrechen muss, statt ihn einfach laufen zu lassen und zu überschreiben. Dieser Test nutzt fakeAsync und tick, um die virtuelle Zeit deterministisch zu steuern: Er lässt 100ms vergehen (bevor das Debounce auslöst), ändert die Query erneut, lässt dann die restlichen 300ms vergehen und prüft, dass nur die Ergebnisse der letzten Query übrig bleiben — ein Beweis, dass der onCleanup-Callback den ersten setTimeout wirklich gelöscht hat, statt nur mit ihm zu wettlaufen.
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
Erklärung
1
effect((onCleanup) => {
Der onCleanup-Callback-Parameter läuft vor der nächsten Effekt-Ausführung oder beim Zerstören und ist damit der richtige Ort, um laufende asynchrone Arbeit abzubrechen.
2
onCleanup(() => clearTimeout(handle));
Das Registrieren des Cleanups innerhalb desselben Effekt-Durchlaufs bindet die Lebensdauer des Timers exakt an den Signal-Read, der ihn erzeugt hat.
3
it('cancels the pending search when the query changes before the debounce fires', fakeAsync(() => {
fakeAsync umschließt den Test in einer Zone, die Timer abfängt, sodass tick() Zeitverlauf ohne echte Verzögerungen simuliert.
4
tick(100);
Das Vorspulen von nur 100ms des 300ms-Debounce-Fensters beweist, dass die Assertion nicht einfach darauf wartet, dass beide Timer irgendwann feuern.
5
expect(component.results()).toEqual(['angular-1', 'angular-2']);
Die Prüfung nur der Ergebnisse der zweiten Query, ohne Spur der ersten, bestätigt, dass der veraltete Timer tatsächlich abgebrochen wurde, statt dass beide aufgelöst wurden.