Kurs JavaScript i React · Moduł 13: Testowanie React
Mockowanie Modułów i API - Symulator Lotów
W tej lekcji4
W prawdziwym centrum kontroli lotów używa się symulatorów, aby trenować pilotów bez ryzyka uszkodzenia prawdziwego statku. W testach React mockowanie pełni dokładnie tę samą rolę - pozwala testować komponenty bez prawdziwych połączeń z API, bazą danych czy zewnętrznymi serwisami.
Test, który za każdym razem woła prawdziwy serwer, jest wolny, zależy od sieci i potrafi paść w nocy tylko dlatego, że API miało przerwę. Mock zamienia zewnętrzny świat na przewidywalną atrapę, którą sam kontrolujesz.
jest.mock() - Zastępowanie modułów
jest.mock() pozwala zastąpić cały moduł jego mockiem. Każda eksportowana funkcja staje się automatycznie atrapą jest.fn():
1// Mockujemy cały moduł API
2jest.mock('./api/missionApi');
3
4import { fetchMission, launchMission } from './api/missionApi';
5
6test('displays mission data', async () => {
7 // Ustawiamy co ma zwrócić mock
8 fetchMission.mockResolvedValue({
9 id: 1,
10 name: 'Apollo 13',
11 status: 'Active'
12 });
13
14 render(<MissionPanel />);
15
16 expect(await screen.findByText('Apollo 13')).toBeInTheDocument();
17 expect(fetchMission).toHaveBeenCalledTimes(1);
18});jest.mock stoi nad importem, ale nawet gdyby był niżej, babel-jest i tak przeniesie go na samą górę pliku. Dzięki temu import dostaje już wersję zmockowaną, a my ustalamy jej wynik przez mockResolvedValue. Funkcja launchMission, której ten test nie używa, też jest teraz atrapą i zwraca undefined, dopóki nie ustawisz jej wyniku.
Mockowanie z implementacją
Drugi argument jest.mock to fabryka, która zwraca własną wersję modułu. Przydaje się, gdy automatyczne atrapy zwracające undefined nie wystarczą:
1jest.mock('./utils/navigation', () => ({
2 calculateRoute: jest.fn((from, to) => ({
3 distance: 100,
4 duration: '2 hours',
5 fuel: 50
6 })),
7 isValidCoordinate: jest.fn(() => true),
8}));Każda funkcja jest nadal jest.fn, więc możesz sprawdzić jej wywołania, ale od razu zwraca sensowne dane.
Częściowe mockowanie
Czasem chcesz podmienić tylko jedną funkcję, a resztę modułu zostawić prawdziwą:
1jest.mock('./utils/spacemath', () => ({
2 ...jest.requireActual('./utils/spacemath'),
3 // Nadpisujemy TYLKO jedną funkcję
4 calculateGravity: jest.fn(() => 9.81),
5}));jest.requireActual wczytuje oryginalny moduł, a rozproszenie ... kopiuje wszystkie jego eksporty. Nadpisujemy tylko calculateGravity, reszta działa jak w produkcji.
Mockowanie fetch / axios
Gdy komponent sam woła fetch lub axios, możemy podmienić właśnie te funkcje.
Mockowanie globalnego fetch
global.fetch to globalna funkcja, więc przed każdym testem zastępujemy ją świeżą atrapą:
1beforeEach(() => {
2 global.fetch = jest.fn();
3});
4
5afterEach(() => {
6 jest.restoreAllMocks();
7});
8
9test('fetches planets from API', async () => {
10 fetch.mockResolvedValueOnce({
11 ok: true,
12 json: () => Promise.resolve([
13 { id: 1, name: 'Mars' },
14 { id: 2, name: 'Jupiter' },
15 ]),
16 });
17
18 render(<PlanetList />);
19
20 expect(await screen.findByText('Mars')).toBeInTheDocument();
21 expect(screen.getByText('Jupiter')).toBeInTheDocument();
22 expect(fetch).toHaveBeenCalledWith('/api/planets');
23});Atrapa zwraca obiekt z polem ok i metodą json, bo tylko z nich korzysta komponent. Uwaga na jest.restoreAllMocks(): przywraca oryginały tylko dla mocków utworzonych przez jest.spyOn. Ręcznie przypisany global.fetch zostaje nadpisany dopiero w kolejnym beforeEach. Do zerowania licznika wywołań służy jest.clearAllMocks().
Sprzątanie mocków między testami
Atrapa pamięta swoje wywołania między testami, więc licznik z jednego testu potrafi zepsuć asercję toHaveBeenCalledTimes w następnym. Dlatego czyszczenie stawia się w beforeEach:
1beforeEach(() => {
2 jest.clearAllMocks();
3});Trzy funkcje sprzątające łatwo pomylić:
jest.clearAllMocks()zeruje historię wywołań (mock.calls,mock.results), ale zostawia ustawione odpowiedzi, np.mockResolvedValuejest.resetAllMocks()robi to samo i dodatkowo usuwa odpowiedzi, więc atrapy znów zwracająundefinedjest.restoreAllMocks()przywraca prawdziwe funkcje podmienione przezjest.spyOn
Ten sam efekt bez pisania beforeEach dają opcje clearMocks, resetMocks i restoreMocks w konfiguracji Jest.
Mockowanie axios
Z biblioteką axios postępujemy jak z każdym modułem. Do wpisywania tekstu używamy userEvent.setup():
1jest.mock('axios');
2import axios from 'axios';
3import userEvent from '@testing-library/user-event';
4
5test('posts new mission', async () => {
6 const user = userEvent.setup();
7 axios.post.mockResolvedValue({
8 data: { id: 42, name: 'New Mission', status: 'Created' }
9 });
10
11 render(<CreateMissionForm />);
12
13 await user.type(screen.getByLabelText('Mission Name'), 'Artemis');
14 await user.click(screen.getByRole('button', { name: 'Create' }));
15
16 expect(axios.post).toHaveBeenCalledWith('/api/missions', {
17 name: 'Artemis'
18 });
19 expect(await screen.findByText('Mission created!')).toBeInTheDocument();
20});Sprawdzamy dwie rzeczy: czy axios.post dostał właściwy adres i dane oraz czy użytkownik zobaczył komunikat o sukcesie.
MSW (Mock Service Worker) - Zaawansowane Mockowanie API
Mockowanie fetch czy axios bezpośrednio działa, ale ma pewną wadę - testujesz implementację (czy użyłeś fetch, czy axios), a nie zachowanie (czy dane zostały pobrane). MSW rozwiązuje ten problem, przechwytując żądania na poziomie sieci. To jak symulator, który udaje prawdziwą komunikację satelitarną - Twój komponent "myśli", że rozmawia z prawdziwym serwerem, ale w rzeczywistości odpowiedzi generuje MSW. Dzięki temu test jest bardziej realistyczny i odporny na zmiany implementacji. Poniższy kod używa API MSW 2, czyli http i HttpResponse. Starsze rest z res(ctx.json()) to składnia MSW 1, usunięta w wersji 2:
1import { http, HttpResponse } from 'msw';
2import { setupServer } from 'msw/node';
3
4// Definiujemy handlery
5const handlers = [
6 http.get('/api/missions', () => {
7 return HttpResponse.json([
8 { id: 1, name: 'Apollo 11', status: 'Completed' },
9 { id: 2, name: 'Artemis I', status: 'Active' },
10 ]);
11 }),
12
13 http.post('/api/missions', async ({ request }) => {
14 const body = await request.json();
15 return HttpResponse.json(
16 { id: 3, name: body.name, status: 'Created' },
17 { status: 201 }
18 );
19 }),
20];
21
22// Tworzymy serwer
23const server = setupServer(...handlers);
24
25// Setup i teardown
26beforeAll(() => server.listen());
27afterEach(() => server.resetHandlers());
28afterAll(() => server.close());
29
30test('lists missions from API', async () => {
31 render(<MissionList />);
32
33 expect(await screen.findByText('Apollo 11')).toBeInTheDocument();
34 expect(screen.getByText('Artemis I')).toBeInTheDocument();
35});setupServer z msw/node uruchamia przechwytywanie w środowisku Node, w którym działa Jest. Handler dla POST odczytuje treść żądania przez await request.json(), tak jak zrobiłby to prawdziwy serwer.
W Jest MSW wymaga dwóch poprawek konfiguracji. Pierwsza: MSW potrzebuje globalnych Request, Response i fetch, których środowisko jsdom nie ma, więc z samym testEnvironment: 'jsdom' test padnie z błędem Request is not defined. Dokumentacja MSW zaleca pakiet jest-fixed-jsdom i ustawienie testEnvironment: 'jest-fixed-jsdom'. Druga: część zależności MSW jest wydana tylko jako moduły ESM, więc na Node starszym niż 24.9 Jest zgłosi Must use import to load ES Module. Wtedy dodaj wskazane w komunikacie pakiety do wyjątków w transformIgnorePatterns i ustaw transform tak, żeby babel-jest obsługiwał też pliki .mjs.
Nadpisywanie handlerów w pojedynczym teście
server.use dodaje handler tylko na czas jednego testu, a resetHandlers w afterEach przywraca stan wyjściowy:
1test('handles server error', async () => {
2 // Tylko dla tego testu -- zwracamy błąd
3 server.use(
4 http.get('/api/missions', () => {
5 return HttpResponse.json({ error: 'Server error' }, { status: 500 });
6 })
7 );
8
9 render(<MissionList />);
10
11 expect(await screen.findByText('Failed to load missions')).toBeInTheDocument();
12});Tak testujesz ścieżkę błędu bez zmieniania wspólnych handlerów. Komponent dostaje status 500 i pokazuje komunikat o awarii.
Mockowanie timerów
Wiele komponentów korzysta z setTimeout lub setInterval - na przykład automatyczne odświeżanie danych co 30 sekund, odliczanie do startu misji czy animacje. W testach nie chcemy czekać prawdziwych 30 sekund! Jest udostępnia fake timery, które pozwalają "przewijać czas" jak w symulatorze lotów. Wywołujesz jest.useFakeTimers(), a potem jest.advanceTimersByTime(ms), aby przeskoczyć o określoną liczbę milisekund. Weźmy panel, który pobiera dane przy montowaniu, a potem co interval milisekund:
1function AutoRefreshPanel({ fetchData, interval }) {
2 const [data, setData] = useState(null);
3
4 useEffect(() => {
5 const load = () => fetchData().then(setData);
6 load();
7 const id = setInterval(load, interval);
8 return () => clearInterval(id);
9 }, [fetchData, interval]);
10
11 return <p>{data ? 'Dane odświeżone' : 'Ładowanie...'}</p>;
12}Test podaje komponentowi atrapę fetchData, która od razu zwraca dane, i przewija zegar o pełne interwały:
1test('auto-refreshes data every 30 seconds', async () => {
2 jest.useFakeTimers();
3 const mockFetch = jest.fn().mockResolvedValue({ data: [] });
4
5 render(<AutoRefreshPanel fetchData={mockFetch} interval={30000} />);
6
7 // Pierwsze wywołanie przy montowaniu
8 expect(mockFetch).toHaveBeenCalledTimes(1);
9
10 // Po 30 sekundach: drugie wywołanie
11 await act(async () => {
12 jest.advanceTimersByTime(30000);
13 });
14 expect(mockFetch).toHaveBeenCalledTimes(2);
15
16 // Po kolejnych 30 sekundach
17 await act(async () => {
18 jest.advanceTimersByTime(30000);
19 });
20 expect(mockFetch).toHaveBeenCalledTimes(3);
21
22 jest.useRealTimers();
23});Każde przewinięcie o 30 sekund odpala jeden cykl odświeżania, więc licznik wywołań rośnie o jeden. Atrapa zwraca Promise, więc nowy stan pojawia się dopiero po jego rozwiązaniu, chwilę po przewinięciu zegara. Dlatego używamy asynchronicznej wersji await act(async () => ...): czeka ona także na takie obietnice, zanim się zakończy. Ze zwykłym act(() => ...) liczniki też by się zgadzały, ale React wypisałby w konsoli trzy ostrzeżenia "not wrapped in act(...)".
Moja rada: do komponentów, które rozmawiają z API, wybieraj MSW, a jest.mock zostaw na moduły bez sieci. W następnej lekcji przetestujesz własne hooki. Pamiętaj: mock to symulator lotu, w którym trenujesz każdy scenariusz, także awarię, zanim ruszysz w prawdziwy kosmos.
Kod do tej lekcji: App.jsx
1import React, { useState, useEffect, useCallback } from 'react';
2
3// ===== Moduł API (w projekcie byłby to osobny plik missionApi.js) =====
4// Podgląd nie ma serwera, więc moduł udaje sieć: Promise + setTimeout.
5// W teście podmieniasz go przez jest.mock('./missionApi') albo MSW.
6const STATUS_LABELS = { completed: 'zakończona', active: 'aktywna', created: 'utworzona' };
7
8let serverMissions = [
9 { id: 1, name: 'Apollo 11', status: 'completed' },
10 { id: 2, name: 'Artemis I', status: 'active' },
11];
12let serverDown = false;
13
14const missionApi = {
15 fetchMissions() {
16 return new Promise((resolve, reject) => {
17 setTimeout(() => {
18 if (serverDown) reject(new Error('HTTP 500'));
19 else resolve([...serverMissions]);
20 }, 700);
21 });
22 },
23 createMission(data) {
24 return new Promise(resolve => {
25 setTimeout(() => {
26 const created = { id: Date.now(), name: data.name, status: 'created' };
27 serverMissions = [...serverMissions, created];
28 resolve(created);
29 }, 400);
30 });
31 },
32 setServerDown(value) {
33 serverDown = value;
34 },
35};
36
37// Komponent, który w testach dostaje zmockowane API
38function MissionCenter() {
39 const [missions, setMissions] = useState([]);
40 const [loading, setLoading] = useState(true);
41 const [error, setError] = useState(null);
42 const [newName, setNewName] = useState('');
43
44 const loadMissions = useCallback(async () => {
45 setLoading(true);
46 try {
47 // W testach: missionApi.fetchMissions.mockResolvedValue([...])
48 const data = await missionApi.fetchMissions();
49 setMissions(data);
50 setError(null);
51 } catch (err) {
52 setError('Nie udało się pobrać misji');
53 } finally {
54 setLoading(false);
55 }
56 }, []);
57
58 useEffect(() => {
59 loadMissions();
60 }, [loadMissions]);
61
62 const handleCreate = async (e) => {
63 e.preventDefault();
64 if (!newName.trim()) return;
65 const created = await missionApi.createMission({ name: newName.trim() });
66 setMissions(prev => [...prev, created]);
67 setNewName('');
68 };
69
70 if (loading) return <div className="status" role="status">Ładowanie misji...</div>;
71
72 if (error) {
73 return (
74 <div className="status error">
75 <p role="alert">{error}</p>
76 <button className="refresh" onClick={loadMissions}>Spróbuj ponownie</button>
77 </div>
78 );
79 }
80
81 return (
82 <div className="mission-center">
83 <h2>Centrum misji</h2>
84
85 <form className="create-form" onSubmit={handleCreate}>
86 <input
87 type="text"
88 placeholder="Nazwa nowej misji..."
89 value={newName}
90 onChange={e => setNewName(e.target.value)}
91 aria-label="Nazwa misji"
92 />
93 <button type="submit">Utwórz</button>
94 </form>
95
96 <ul className="mission-list">
97 {missions.map(m => (
98 <li key={m.id}>{m.name} - {STATUS_LABELS[m.status]}</li>
99 ))}
100 </ul>
101
102 <button className="refresh" onClick={loadMissions}>
103 Odśwież
104 </button>
105 </div>
106 );
107}
108
109// Automatyczne odświeżanie: w testach przewijasz czas fake timerami
110function AutoRefreshStatus({ interval = 5000 }) {
111 const [systemStatus, setSystemStatus] = useState({
112 engine: 'w normie',
113 shields: 100,
114 lastUpdate: new Date().toLocaleTimeString('pl-PL'),
115 });
116 const [refreshCount, setRefreshCount] = useState(0);
117
118 useEffect(() => {
119 const timer = setInterval(() => {
120 setRefreshCount(c => c + 1);
121 setSystemStatus(prev => ({
122 ...prev,
123 shields: Math.max(prev.shields - 1, 0),
124 lastUpdate: new Date().toLocaleTimeString('pl-PL'),
125 }));
126 }, interval);
127
128 return () => clearInterval(timer);
129 }, [interval]);
130
131 return (
132 <div className="auto-refresh">
133 <h2>Stan systemów (odświeżanie co {interval / 1000} s)</h2>
134 <div className="status-grid">
135 <div>Silnik: {systemStatus.engine}</div>
136 <div>Osłony: {systemStatus.shields}%</div>
137 <div>Ostatni odczyt: {systemStatus.lastUpdate}</div>
138 <div>Liczba odświeżeń: {refreshCount}</div>
139 </div>
140 </div>
141 );
142}
143
144export default function App() {
145 const [down, setDown] = useState(false);
146 const [run, setRun] = useState(0);
147
148 const toggleServer = (e) => {
149 missionApi.setServerDown(e.target.checked);
150 setDown(e.target.checked);
151 setRun(r => r + 1);
152 };
153
154 return (
155 <div className="app">
156 <h1>Mockowanie modułów i API</h1>
157 <p className="info">
158 Podgląd nie uruchamia Jesta. Tutaj moduł missionApi udaje serwer
159 (Promise + setTimeout), a w testach z lekcji podmieniasz go mockiem.
160 </p>
161 <label className="server-toggle">
162 <input type="checkbox" checked={down} onChange={toggleServer} />
163 Symuluj awarię serwera (HTTP 500)
164 </label>
165 <MissionCenter key={run} />
166 <AutoRefreshStatus interval={3000} />
167 </div>
168 );
169}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Co robi jest.mock('./api/missionApi') w teście?
2. Jaka jest główna zaleta MSW (Mock Service Worker) w porównaniu z mockowaniem fetch?
Zadania praktyczne w grze
- Układanie w pionie
Ułóż cykl życia mockowanego fetch w testach:
- Edytor kodu
Dokończ reducer missionReducer i hook useMissionTracker dziennika misji. ___BLANK1___: akcja START zmienia status zaplanowanej misji na 'active', zwracając nowy obiekt z pozostałymi polami misji (nie zmieniaj obiektu, który jest w stanie). ___BLANK2___: akcja REMOVE zwraca nową tablicę bez misji o id z akcji (action.id). ___BLANK3___: hook przekazuje do useReducer reducer missionReducer. Test sprawdza reducer jak czystą funkcję na zamrożonym stanie (Object.freeze), a hook uruchamia w małym komponencie testowym: dodaje dwie misje, startuje, kończy i usuwa.
- Klikanie w kolejności
Ułóż składnię tworzenia mocka z wartością Promise:
- Układanie w poziomie
Ułóż składnię bloku beforeEach z czyszczeniem mocków: