Kurs JavaScript i React · Moduł 13: Testowanie React
Jest - Silnik Rakietowy Testów
W tej lekcji9
Przed każdym startem inżynierowie sprawdzają setki parametrów: ciśnienie paliwa, temperaturę silników, łączność. Nikt nie robi tego ręcznie przy każdej zmianie w rakiecie, bo trwałoby to tygodnie. W kodzie jest tak samo: po każdej poprawce chcesz w kilka sekund wiedzieć, czy nic się nie zepsuło, i potrzebujesz do tego narzędzia, które uruchomi wszystkie kontrole naraz.
Jest to framework testowy stworzony przez Facebooka (Meta), który stanowi fundament testowania aplikacji React. Od 2022 roku rozwija go fundacja OpenJS. Pomyśl o nim jak o głównym silniku rakietowym - bez niego żaden test nie ruszy z miejsca. Jest zawiera w jednym pakiecie wszystko: uruchamianie testów, asercje, mocki i raport pokrycia.
Gdzie Jest szuka testów
Domyślnie Jest znajduje pliki z końcówką .test.js lub .spec.js (także .jsx, .ts, .tsx) w dowolnym katalogu projektu oraz wszystkie pliki w folderach __tests__. Zwykle kładzie się test obok komponentu, np. SpaceShip.test.js przy SpaceShip.js. Takie testy pojedynczych funkcji i komponentów to testy jednostkowe: leżą u podstawy piramidy testów, bo są najszybsze i najtańsze.
Struktura testu - describe i it/test
Testy organizujemy w bloki describe (opisujące grupę testów) i it lub test (pojedyncze testy). describe grupuje powiązane testy, a w środku każdy it sprawdza jedno zachowanie za pomocą funkcji expect. W przykładzie funkcja createSpaceShip() tworzy statek z pełnym bakiem (fuel równe 100) i metodą travel, która zużywa paliwo, a przy pustym baku rzuca błąd No fuel:
1describe('SpaceShip', () => {
2 it('should have full fuel at start', () => {
3 const ship = createSpaceShip();
4 expect(ship.fuel).toBe(100);
5 });
6
7 it('should consume fuel during travel', () => {
8 const ship = createSpaceShip();
9 ship.travel(50);
10 expect(ship.fuel).toBeLessThan(100);
11 });
12
13 test('should not travel without fuel', () => {
14 const ship = createSpaceShip();
15 ship.fuel = 0;
16 expect(() => ship.travel(10)).toThrow('No fuel');
17 });
18});it i test to aliasy - robią dokładnie to samo. Konwencja mówi: it('should...') lub test('returns...'). Każdy test tworzy własny statek, więc testy nie dzielą stanu i mogą działać w dowolnej kolejności.
Matchery (asercje) - Czujniki Diagnostyczne
Matchery to funkcje, które sprawdzają, czy wynik jest taki, jak oczekujemy. Jest oferuje bogaty zestaw. Schemat jest zawsze ten sam: expect(wartość), a po kropce matcher z oczekiwaniem.
Porównanie wartości
Najczęściej porównujesz wynik z oczekiwaną wartością. Tu kryje się najważniejsza różnica w całym Jest:
1// Dokładna równość
2expect(2 + 2).toBe(4);
3
4// Porównanie obiektów (deep equality)
5expect({ name: 'Apollo' }).toEqual({ name: 'Apollo' });
6
7// Negacja
8expect(fuel).not.toBe(0);toBe porównuje przez Object.is, więc dla dwóch osobnych obiektów zwróci fałsz nawet przy identycznej zawartości. Do obiektów i tablic używaj toEqual, które sprawdza zawartość pole po polu.
Prawdziwość
Kolejna grupa matcherów sprawdza, czy wartość w ogóle istnieje i jak zachowuje się w warunku if:
1expect(null).toBeNull();
2expect(undefined).toBeUndefined();
3expect('Apollo').toBeDefined();
4expect(true).toBeTruthy();
5expect(0).toBeFalsy();toBeTruthy i toBeFalsy są luźne, bo 0, pusty napis i null to wszystko wartości fałszywe. Gdy znasz dokładną wartość, wybierz precyzyjny matcher.
Liczby
Liczby mają własne porównania, przydatne przy prędkości czy poziomie paliwa:
1expect(speed).toBeGreaterThan(0);
2expect(fuel).toBeLessThanOrEqual(100);
3expect(0.1 + 0.2).toBeCloseTo(0.3);toBeCloseTo ratuje przy ułamkach, bo 0.1 + 0.2 w JavaScript to 0.30000000000000004, a toBe(0.3) by nie przeszło.
Stringi
Tekst sprawdzisz wyrażeniem regularnym albo szukając fragmentu:
1expect('Mission Control').toMatch(/mission/i);
2expect('Apollo 13').toContain('Apollo');Flaga i w wyrażeniu sprawia, że wielkość liter nie ma znaczenia.
Tablice i obiekty
Kolekcje mają matchery do sprawdzania zawartości, długości i właściwości:
1expect(['Earth', 'Mars', 'Jupiter']).toContain('Mars');
2expect(crewMembers).toHaveLength(5);
3expect(spaceship).toHaveProperty('engine');
4expect(spaceship).toHaveProperty('crew.captain', 'Nova');toHaveProperty przyjmuje ścieżkę z kropkami, więc sięgnie do zagnieżdżonego pola crew.captain i porówna jego wartość.
Wyjątki
Sprawdzając błędy, pamiętaj o opakowaniu wywołania w funkcję strzałkową:
1expect(() => launchWithoutFuel()).toThrow();
2expect(() => divideByZero()).toThrow('Division by zero');
3expect(() => invalidInput()).toThrow(ValidationError);Gdybyś napisał expect(launchWithoutFuel()), błąd poleciałby przed expect i test by się wysypał, zamiast go złapać.
Funkcje czyste - najłatwiejszy cel testów
Zanim zaczniesz sprawdzać kod, warto go napisać tak, żeby dało się go łatwo sprawdzić. Spójrz na funkcję, która zużywa paliwo zapisane w zmiennej poza nią:
1// Funkcja nieczysta: zmienia zmienną spoza siebie
2let fuel = 100;
3
4function burnFuel(amount) {
5 fuel = fuel - amount;
6 return fuel;
7}Wynik burnFuel(10) zależy od tego, ile razy funkcję wcześniej wywołano: za pierwszym razem zwróci 90, za drugim 80. Test, który oczekuje 90, przejdzie albo padnie zależnie od kolejności testów. Funkcja czysta nie ma tego problemu: dla tych samych argumentów zawsze zwraca ten sam wynik i niczego poza sobą nie zmienia. Błędne dane też obsługuje przewidywalnie, rzucając błąd, który test złapie przez toThrow:
1// Funkcja czysta: wynik zależy tylko od argumentów
2function calculateTravelTime(distance, speed) {
3 if (speed <= 0) {
4 throw new Error('Speed must be positive');
5 }
6 return distance / speed;
7}
8
9test('calculates travel time', () => {
10 expect(calculateTravelTime(300, 100)).toBe(3);
11});
12
13test('throws when speed is not positive', () => {
14 expect(() => calculateTravelTime(300, 0)).toThrow('Speed must be positive');
15});Każdy test to jedno wywołanie i jedno expect, bez przygotowań i sprzątania. Dlatego logikę, którą chcesz sprawdzać, np. obliczenia paliwa czy walidację współrzędnych, wydzielaj do osobnych funkcji czystych, a komponent niech tylko pokazuje ich wynik.
Setup i Teardown - Przygotowanie do Startu
Często testy wymagają przygotowania i czyszczenia środowiska. Jest daje do tego cztery funkcje cyklu życia:
1describe('MissionControl', () => {
2 let missionControl;
3
4 // Przed KAŻDYM testem
5 beforeEach(() => {
6 missionControl = new MissionControl();
7 missionControl.initialize();
8 });
9
10 // Po KAŻDYM teście
11 afterEach(() => {
12 missionControl.shutdown();
13 });
14
15 // Przed WSZYSTKIMI testami w grupie
16 beforeAll(() => {
17 console.log('Starting mission control tests');
18 });
19
20 // Po WSZYSTKICH testach w grupie
21 afterAll(() => {
22 console.log('All tests completed');
23 });
24
25 it('should have zero active missions at start', () => {
26 expect(missionControl.activeMissions).toBe(0);
27 });
28});Moja rada: preferuj beforeEach, bo każdy test dostaje świeży obiekt. beforeAll zostaw dla drogich operacji, jak start serwera testowego.
Testowanie asynchroniczne w Jest
Wiele operacji w kosmosie wymaga czasu - tak samo w kodzie. Jest musi wiedzieć, że ma poczekać na wynik:
1// Zwracanie Promise
2test('fetches crew data', () => {
3 return fetchCrewData().then(data => {
4 expect(data).toHaveLength(5);
5 });
6});
7
8// Async/await (preferowane)
9test('fetches crew data', async () => {
10 const data = await fetchCrewData();
11 expect(data).toHaveLength(5);
12});
13
14// Testowanie odrzuconego Promise
15test('throws on invalid mission', async () => {
16 await expect(fetchMission(-1)).rejects.toThrow('Invalid mission ID');
17});Bez return lub await test skończyłby się przed odpowiedzią i przeszedłby, niczego nie sprawdzając.
Funkcje mock w Jest - Symulatory Lotów
Funkcje mock (atrapy) pozwalają symulować zachowanie funkcji bez ich prawdziwego wywoływania. jest.fn() tworzy funkcję, która zapamiętuje każde wywołanie:
1// Tworzenie funkcji mock
2const mockLaunch = jest.fn();
3
4// Wywołanie
5mockLaunch('Apollo', 'Moon');
6
7// Sprawdzenie
8expect(mockLaunch).toHaveBeenCalled();
9expect(mockLaunch).toHaveBeenCalledTimes(1);
10expect(mockLaunch).toHaveBeenCalledWith('Apollo', 'Moon');
11
12// Mock z wartością zwrotną
13const mockFuel = jest.fn().mockReturnValue(100);
14expect(mockFuel()).toBe(100);
15
16// Mock z implementacją
17const mockCalculateRoute = jest.fn((from, to) => {
18 return { distance: 1000, estimatedTime: '2h' };
19});Mock nie odpala prawdziwej rakiety, a mimo to możesz sprawdzić, ile razy i z jakimi argumentami go wywołano. Więcej o mockowaniu modułów w dalszej lekcji.
Uruchamianie testów
Jest oferuje kilka przydatnych komend do uruchamiania testów. Zwykle wołasz je przez skrypt test z package.json:
1# Uruchom wszystkie testy
2npm test
3
4# Uruchom testy w trybie watch (automatycznie po zmianach)
5npm test -- --watch
6
7# Uruchom testy z konkretnego pliku
8npm test -- SpaceShip.test.js
9
10# Uruchom testy pasujące do wzorca
11npm test -- --testPathPatterns="mission"
12
13# Uruchom z raportem pokrycia kodu
14npm test -- --coveragePodwójny myślnik przekazuje opcje dalej, do Jest. Od Jest 30 opcja filtrowania ścieżek nazywa się --testPathPatterns, a starsze --testPathPattern kończy się błędem. Tryb --watch uruchamia tylko testy związane ze zmienionymi plikami, a zmiany wykrywa przez git, więc działa w repozytorium. W katalogu bez gita Jest poprosi o --watchAll, który po każdej zmianie uruchamia wszystkie testy.
Dobre praktyki testowania z Jest
- Jedno zachowanie na test - każdy test powinien sprawdzać jedną konkretną rzecz; kilka asercji o tym samym zachowaniu jest w porządku
- Nazwy opisowe -
it('should calculate fuel consumption for 100km trip')zamiastit('test fuel') - Arrange-Act-Assert - przygotuj dane, wykonaj akcję, sprawdź wynik
- Izolacja - testy nie powinny zależeć od siebie nawzajem ani od kolejności uruchamiania
W następnej lekcji połączysz Jest z React Testing Library i zaczniesz testować komponenty.
Jest to potężny silnik rakietowy testów - opanowanie go da Ci pewność, że Twoja aplikacja działa poprawnie w każdych warunkach kosmicznych!
Kod do tej lekcji: App.jsx
1import React, { useState } from 'react';
2
3// ===== Funkcje do przetestowania =====
4
5function calculateFuelConsumption(distance, speed) {
6 if (speed <= 0) throw new Error('Prędkość musi być dodatnia');
7 return distance / speed * 1.5;
8}
9
10function sortByPriority(missions) {
11 return [...missions].sort((a, b) => a.priority - b.priority);
12}
13
14// ===== Miniaturowe expect =====
15// Podgląd nie uruchamia Jest, więc ta funkcja udaje trzy jego matchery.
16// Tak jak w Jest, nieudana asercja rzuca błąd.
17
18function expect(actual) {
19 return {
20 toBe(expected) {
21 if (Object.is(actual, expected)) return;
22 if (JSON.stringify(actual) === JSON.stringify(expected)) {
23 throw new Error('ta sama zawartość, ale to dwa różne obiekty - porównaj je przez toEqual');
24 }
25 throw new Error('oczekiwano ' + JSON.stringify(expected) + ', otrzymano ' + JSON.stringify(actual));
26 },
27 toEqual(expected) {
28 if (JSON.stringify(actual) !== JSON.stringify(expected)) {
29 throw new Error('oczekiwano ' + JSON.stringify(expected) + ', otrzymano ' + JSON.stringify(actual));
30 }
31 },
32 toThrow(message) {
33 try {
34 actual();
35 } catch (error) {
36 if (error.message === message) return;
37 throw new Error('rzucono błąd z innym komunikatem: ' + error.message);
38 }
39 throw new Error('funkcja nie rzuciła błędu');
40 },
41 };
42}
43
44function runTest(name, fn) {
45 try {
46 fn();
47 return { name, passed: true };
48 } catch (error) {
49 return { name, passed: false, details: error.message };
50 }
51}
52
53// ===== Raport w stylu Jest =====
54
55function TestResult({ name, passed, details }) {
56 return (
57 <div className={'test-result ' + (passed ? 'pass' : 'fail')}>
58 <span className="icon">{passed ? '√' : '×'}</span>
59 <span className="name">{name}</span>
60 {details && <span className="details">{details}</span>}
61 </div>
62 );
63}
64
65function TestSuite({ title, tests }) {
66 const allPassed = tests.every((t) => t.passed);
67 return (
68 <div className="test-suite">
69 <h3>
70 <span className={'badge ' + (allPassed ? 'pass' : 'fail')}>
71 {allPassed ? 'PASS' : 'FAIL'}
72 </span>
73 {title}
74 </h3>
75 {tests.map((test) => (
76 <TestResult key={test.name} {...test} />
77 ))}
78 </div>
79 );
80}
81
82const MISSIONS = [
83 { name: 'Mars', priority: 2 },
84 { name: 'Księżyc', priority: 1 },
85];
86
87export default function App() {
88 const [matcher, setMatcher] = useState('toBe');
89 const other = matcher === 'toBe' ? 'toEqual' : 'toBe';
90
91 const fuelTests = [
92 runTest('oblicza 3 jednostki na 100 km przy 50 km/s', () => {
93 expect(calculateFuelConsumption(100, 50)).toBe(3);
94 }),
95 runTest('zwraca 0 dla dystansu 0', () => {
96 expect(calculateFuelConsumption(0, 50)).toBe(0);
97 }),
98 runTest('rzuca błąd przy prędkości 0', () => {
99 expect(() => calculateFuelConsumption(100, 0)).toThrow('Prędkość musi być dodatnia');
100 }),
101 ];
102
103 const sortTests = [
104 runTest('ustawia misje według priorytetu (' + matcher + ')', () => {
105 expect(sortByPriority(MISSIONS))[matcher]([
106 { name: 'Księżyc', priority: 1 },
107 { name: 'Mars', priority: 2 },
108 ]);
109 }),
110 ];
111
112 const all = [...fuelTests, ...sortTests];
113 const passedCount = all.filter((t) => t.passed).length;
114
115 return (
116 <div className="app">
117 <h1>Jest - Silnik rakietowy testów</h1>
118 <p>Poniżej widzisz wyniki testów uruchomionych "na żywo" w podglądzie.</p>
119 <TestSuite title="calculateFuelConsumption" tests={fuelTests} />
120 <TestSuite title="sortByPriority" tests={sortTests} />
121 <button className="switch" onClick={() => setMatcher(other)}>
122 Zamień {matcher} na {other}
123 </button>
124 <p className="hint">
125 toBe porównuje przez Object.is, czyli sprawdza, czy to ten sam obiekt.
126 sortByPriority zwraca nową tablicę, więc jej zawartość sprawdzisz tylko przez toEqual.
127 </p>
128 <p className="summary">
129 Zaliczone: {passedCount}, niezaliczone: {all.length - passedCount}, łącznie: {all.length}
130 </p>
131 </div>
132 );
133}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Który typ testów znajduje się u podstawy piramidy testów i jest najszybszy?
2. Do czego służy blok describe() w Jest?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Uzupełnij dwie czyste funkcje nawigacyjne, które sprawdzają testy (wyniki widzisz w panelu testów, a podgląd pokazuje mini raport PASS/FAIL). ___BLANK1___: calculateFuelConsumption rzuca błąd, gdy prędkość nie jest większa od zera (0 albo liczba ujemna). ___BLANK2___: funkcja zwraca zużycie paliwa jak w lekcji: dystans podzielony przez prędkość, razy 1.5 (calculateFuelConsumption(100, 50) daje 3). ___BLANK3___: isValidCoordinate zwraca true tylko dla liczby od -1000 do 1000 włącznie, a false dla wszystkiego innego: liczb spoza zakresu, NaN, Infinity, napisów takich jak '42', null i undefined.
- Klikanie w kolejności
Ułóż asercję Jest, która sprawdza, czy wartość result wynosi 42: