Kurs JavaScript i TypeScript · Moduł 11: Testowanie z Jest
Testowanie kodu asynchronicznego
W tej lekcji3
Skanowanie próbki DNA nie kończy się w chwili wywołania funkcji. Wynik przychodzi później, jako obietnica (Promise), a test, który na nią nie poczeka, skończy się przed nadejściem wyniku i zamelduje sukces, choć niczego nie sprawdził. Taki fałszywy spokój jest groźniejszy niż czerwony alarm.
W Parku Jurajskim wiele operacji jest asynchronicznych - skanowanie DNA, komunikacja z sensorami, pobieranie danych z satelity. Jest oferuje kilka sposobów testowania takiego kodu.
Testowanie Promise
async/await
Najbardziej czytelny sposób testowania asynchronicznego kodu wymaga dwóch kroków: oznacz funkcję testową jako async i użyj await przy wywołaniu testowanej funkcji. Zakładamy, że fetch jest tu podmieniony mockiem, jak w poprzedniej lekcji, i zwraca próbkę T-Rexa o czystości 0.95:
1// Funkcja asynchroniczna do testowania
2async function scanDNA(sampleId) {
3 const response = await fetch(`/api/dna/${sampleId}`);
4 const data = await response.json();
5 return {
6 species: data.species,
7 purity: data.purity,
8 viable: data.purity > 0.9
9 };
10}
11
12// Test z async/await
13test('skanowanie DNA zwraca gatunek', async () => {
14 const result = await scanDNA('SAMPLE-001');
15
16 expect(result.species).toBe('T-Rex');
17 expect(result.purity).toBeGreaterThan(0.9);
18 expect(result.viable).toBe(true);
19});Jest widzi, że funkcja testowa zwraca obietnicę, i czeka na nią przed oceną wyniku. Bez await zmienna result byłaby obietnicą, a nie obiektem, więc result.species dałoby undefined. Sama funkcja scanDNA się nie zmienia - zmienia się tylko to, że test cierpliwie czeka na jej odpowiedź.
resolves i rejects
Jest posiada dedykowane matchery dla Promise. .resolves rozpakowuje wartość spełnionej obietnicy, a .rejects powód odrzucenia, po czym dopisujesz zwykły matcher. Całe wyrażenie poprzedzasz await (albo zwracasz przez return). Nasz mock fetch dla próbki INVALID odrzuca obietnicę błędem "Sample not found":
1// Testowanie resolve
2test('DNA scan resolves with data', async () => {
3 await expect(scanDNA('SAMPLE-001')).resolves.toEqual({
4 species: 'T-Rex',
5 purity: 0.95,
6 viable: true
7 });
8});
9
10// Testowanie reject
11test('DNA scan rejects for invalid sample', async () => {
12 await expect(scanDNA('INVALID')).rejects.toThrow('Sample not found');
13});Brak await przed expect to klasyczny błąd: test skończy się, zanim matcher cokolwiek sprawdzi. Ta sama zasada obowiązuje przy funkcjach async, które same rzucają wyjątek - jak system obrony, który bez numeru strefy odmawia aktywacji:
1// Inny przykład - testowanie odrzuconej Promise
2async function activateDefense(zoneId) {
3 if (!zoneId) {
4 throw new Error('Zone ID is required');
5 }
6 return { status: 'activated', zone: zoneId };
7}
8
9test('defense rejects without zone ID', async () => {
10 await expect(activateDefense(null)).rejects.toThrow('Zone ID is required');
11});
12
13test('defense resolves with valid zone', async () => {
14 await expect(activateDefense('A')).resolves.toEqual({
15 status: 'activated',
16 zone: 'A'
17 });
18});activateDefense jest funkcją async, więc throw w jej wnętrzu nie wybucha od razu, tylko zamienia się w odrzuconą obietnicę. Dlatego testujemy ją przez rejects.toThrow, a nie przez znane z lekcji o matcherach expect(() => ...).toThrow(). Jeśli wolisz klasyczne try/catch, dodaj na początku testu expect.assertions(1). Wtedy obietnica, która niespodziewanie się spełni, nie prześlizgnie się obok bloku catch bez żadnej asercji.
Testowanie callbacków
Starszy kod nie zwraca obietnic, tylko przyjmuje callback, czyli funkcję wywoływaną z wynikiem. fetchDinoReport wzorem Node.js przekazuje do callbacku najpierw błąd, a potem dane. Test dostaje wtedy parametr done i musi go wywołać, gdy skończy:
1function fetchDinoReport(id, callback) {
2 setTimeout(() => {
3 if (id === 'unknown') {
4 callback(new Error('Dinosaur not found'), null);
5 } else {
6 callback(null, { id, status: 'healthy' });
7 }
8 }, 100);
9}
10
11// Test callbacku z done
12test('callback zwraca raport dinozaura', (done) => {
13 fetchDinoReport('REX-001', (error, report) => {
14 try {
15 expect(error).toBeNull();
16 expect(report.status).toBe('healthy');
17 done(); // Sygnalizuj zakończenie testu
18 } catch (err) {
19 done(err); // Przekaż błąd do Jest
20 }
21 });
22});Jeśli done nigdy nie zostanie wywołane, test padnie po przekroczeniu limitu czasu, domyślnie 5 sekund. try/catch jest potrzebny, bo nieudany expect rzuca wyjątek wewnątrz callbacku, a przekazany do done(err) trafia do raportu jako prawdziwy powód porażki. Nie łącz done z testem async, bo Jest zgłosi wtedy błąd. W nowym kodzie polecam async/await a done zostaw dla starych API.
Fake Timers
Gdy kod używa setTimeout, setInterval lub Date, możesz kontrolować czas. Czekanie 30 sekund w teście to strata czasu, więc jest.useFakeTimers() podmienia zegary na sztuczne. Oto funkcja, która ogłasza ewakuację po 30 sekundach:
1// Funkcja z timeoutem
2function startEmergencyCountdown(callback) {
3 console.log('Ewakuacja za 30 sekund!');
4 setTimeout(() => {
5 callback('Ewakuacja rozpoczęta!');
6 }, 30000); // 30 sekund
7}A tak ją testujemy. jest.advanceTimersByTime przewija czas o podaną liczbę milisekund, natychmiast. Sztuczne zegary włączamy w beforeEach i wyłączamy w afterEach, żeby nie wpłynęły na inne testy:
1describe('Emergency System', () => {
2 beforeEach(() => {
3 jest.useFakeTimers(); // Włącz sztuczne timery
4 });
5
6 afterEach(() => {
7 jest.useRealTimers(); // Przywróć prawdziwe timery
8 });
9
10 test('countdown wywołuje callback po 30s', () => {
11 const callback = jest.fn();
12
13 startEmergencyCountdown(callback);
14
15 // Callback jeszcze nie został wywołany
16 expect(callback).not.toHaveBeenCalled();
17
18 // Przewiń czas o 30 sekund
19 jest.advanceTimersByTime(30000);
20
21 // Teraz callback został wywołany!
22 expect(callback).toHaveBeenCalledWith('Ewakuacja rozpoczęta!');
23 });
24
25 test('countdown nie odpala się przed czasem', () => {
26 const callback = jest.fn();
27
28 startEmergencyCountdown(callback);
29
30 // Przewiń o 29 sekund
31 jest.advanceTimersByTime(29000);
32 expect(callback).not.toHaveBeenCalled();
33
34 // Przewiń o kolejną sekundę (łącznie 30s)
35 jest.advanceTimersByTime(1000);
36 expect(callback).toHaveBeenCalled();
37 });
38});Drugi test pokazuje, że sztuczny czas jest precyzyjny: po 29 sekundach callback wciąż czeka, a po kolejnej sekundzie odpala. Oba testy trwają milisekundy, a nie pół minuty. Kod produkcyjny nie wie, że czas jest fałszywy - nie zmieniliśmy w nim ani jednej linii.
Uruchamianie oczekujących timerów
Gdy nie chcesz liczyć milisekund, jest.runAllTimers() uruchamia wszystkie oczekujące timery naraz. Ważne: sztuczne zegary trzeba włączyć, zanim kod zaplanuje timery, dlatego jest.useFakeTimers() stoi na samym początku testu:
1test('runAllTimers - uruchom wszystkie timery', () => {
2 jest.useFakeTimers();
3
4 const steps = [];
5
6 setTimeout(() => steps.push('krok 1'), 1000);
7 setTimeout(() => steps.push('krok 2'), 2000);
8 setTimeout(() => steps.push('krok 3'), 5000);
9
10 // Uruchom WSZYSTKIE oczekujące timery naraz
11 jest.runAllTimers();
12
13 expect(steps).toEqual(['krok 1', 'krok 2', 'krok 3']);
14
15 jest.useRealTimers();
16});Timery zaplanowane przed włączeniem sztucznych zegarów trafiłyby do prawdziwej kolejki, a runAllTimers by ich nie zobaczył, więc tablica steps zostałaby pusta. Przy setInterval uważaj: runAllTimers uruchamia też timery tworzone w trakcie, więc interwał kręciłby się bez końca, aż Jest przerwie pętlę z błędem. Wtedy użyj jest.runOnlyPendingTimers(), a gdy timery mieszają się z obietnicami, sięgnij po await jest.advanceTimersByTimeAsync(ms).
W projekcie końcowym sztuczne zegary przydadzą się do monitoringu czujników. W laboratorium poniżej zobaczysz symulację skanowania DNA z obietnicami i timerami.
Pamiętaj: w teście asynchronicznym zawsze czekaj na wynik - await to strażnik, który nie opuści posterunku, zanim skan DNA się zakończy.
Kod do tej lekcji: index.js
1// Testowanie kodu asynchronicznego
2console.log("=== Park Jurajski - Asynchroniczne Skanowanie DNA ===\n");
3
4// --- async/await w testach ---
5console.log("--- async/await ---\n");
6
7async function scanDNA(sampleId) {
8 // Symulacja asynchronicznego skanowania
9 return new Promise((resolve, reject) => {
10 setTimeout(() => {
11 if (sampleId === "INVALID") {
12 reject(new Error("Sample not found"));
13 return;
14 }
15 resolve({
16 species: "T-Rex",
17 purity: 0.95,
18 viable: true,
19 });
20 }, 100);
21 });
22}
23
24// Test async/await
25async function testAsyncAwait() {
26 try {
27 const result = await scanDNA("SAMPLE-001");
28 console.log("[PASS] DNA scan returned:", result.species);
29 console.log("[PASS] Purity:", result.purity, "> 0.9?", result.purity > 0.9);
30 console.log("[PASS] Viable:", result.viable);
31 } catch (e) {
32 console.log("[FAIL]", e.message);
33 }
34}
35
36// Test reject
37async function testReject() {
38 try {
39 await scanDNA("INVALID");
40 console.log("[FAIL] Should have thrown");
41 } catch (e) {
42 console.log("[PASS] Rejected:", e.message);
43 }
44}
45
46// --- Callback testing ---
47console.log("--- Callbacki ---\n");
48
49function fetchDinoReport(id, callback) {
50 setTimeout(() => {
51 if (id === "unknown") {
52 callback(new Error("Dinosaur not found"), null);
53 } else {
54 callback(null, { id, status: "healthy" });
55 }
56 }, 50);
57}
58
59function testCallback() {
60 return new Promise((resolve) => {
61 fetchDinoReport("REX-001", (error, report) => {
62 if (!error && report.status === "healthy") {
63 console.log("[PASS] Callback: report status =", report.status);
64 } else {
65 console.log("[FAIL] Callback failed");
66 }
67 resolve();
68 });
69 });
70}
71
72// --- Fake Timers ---
73console.log("--- Fake Timers ---\n");
74
75function demonstrateFakeTimers() {
76 console.log("W prawdziwym Jest:");
77 console.log(" jest.useFakeTimers() - wlacz sztuczne timery");
78 console.log(" jest.advanceTimersByTime(30000) - przewin o 30s");
79 console.log(" jest.runAllTimers() - uruchom wszystkie timery");
80 console.log(" jest.useRealTimers() - przywroc prawdziwe timery");
81
82 console.log("\nPrzyklad:");
83 console.log(" function startCountdown(cb) {");
84 console.log(" setTimeout(() => cb('GO!'), 30000);");
85 console.log(" }");
86 console.log(" ");
87 console.log(" test('countdown', () => {");
88 console.log(" jest.useFakeTimers();");
89 console.log(" const cb = jest.fn();");
90 console.log(" startCountdown(cb);");
91 console.log(" expect(cb).not.toHaveBeenCalled();");
92 console.log(" jest.advanceTimersByTime(30000);");
93 console.log(" expect(cb).toHaveBeenCalledWith('GO!');");
94 console.log(" });");
95}
96
97// --- resolves/rejects ---
98console.log("--- resolves / rejects matchery ---\n");
99
100async function demonstrateResolves() {
101 // resolves
102 const goodPromise = Promise.resolve({ status: "activated", zone: "A" });
103 const result = await goodPromise;
104 const matches = result.status === "activated" && result.zone === "A";
105 console.log("[" + (matches ? "PASS" : "FAIL") + "] resolves.toEqual:", JSON.stringify(result));
106
107 // rejects
108 const badPromise = Promise.reject(new Error("Zone ID is required"));
109 try {
110 await badPromise;
111 console.log("[FAIL] Should have rejected");
112 } catch (e) {
113 const matches = e.message === "Zone ID is required";
114 console.log("[" + (matches ? "PASS" : "FAIL") + "] rejects.toThrow:", e.message);
115 }
116}
117
118// Uruchom testy
119async function runAll() {
120 await testAsyncAwait();
121 console.log("");
122 await testReject();
123 console.log("");
124 await testCallback();
125 console.log("");
126 await demonstrateResolves();
127 console.log("");
128 demonstrateFakeTimers();
129}
130
131runAll();Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jak prawidłowo testować funkcję async w Jest?
2. Która metoda Jest pozwala przewinąć sztuczny czas o określoną liczbę milisekund?
Zadania praktyczne w grze
- Układanie w pionie
Ułóż kroki testowania asynchronicznej funkcji:
- Edytor kodu
Napisz testy async/await dla funkcji analyzeDNA i cloneDinosaur.
- Klikanie w kolejności
Ułóż elementy użycia fake timers w teście:
- Edytor kodu
Użyj fake timers do przetestowania systemu ewakuacji parku.