Kurs JavaScript i TypeScript · Moduł 11: Testowanie z Jest

Testowanie kodu asynchronicznego

6 min czytania
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. 1. Jak prawidłowo testować funkcję async w Jest?

  2. 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.

Przydatne artykuły