Kurs JavaScript i React · Moduł 13: Testowanie React

Mockowanie Modułów i API - Symulator Lotów

7 min czytania
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. mockResolvedValue
  • jest.resetAllMocks() robi to samo i dodatkowo usuwa odpowiedzi, więc atrapy znów zwracają undefined
  • jest.restoreAllMocks() przywraca prawdziwe funkcje podmienione przez jest.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. 1. Co robi jest.mock('./api/missionApi') w teście?

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

Przydatne artykuły