Kurs JavaScript i React · Moduł 13: Testowanie React
Testowanie Komponentów Asynchronicznych - Sygnały z Głębokiego Kosmosu
W tej lekcji5
Wiele operacji w aplikacjach React działa asynchronicznie - pobieranie danych z API, ładowanie zasobów, opóźnione aktualizacje. To jak odbieranie sygnałów z głębokiego kosmosu - odpowiedź nie przychodzi natychmiast, trzeba poczekać.
Zwykły test sprawdza ekran od razu po render. Jeśli dane przyjdą 50 ms później, asercja już dawno się wykonała i test padnie, choć komponent działa. Potrzebujemy narzędzi, które poczekają na sygnał, ale nie w nieskończoność.
waitFor - Czekanie na sygnał
Funkcja waitFor powtarza sprawdzenie asercji aż do momentu, gdy zakończy się sukcesem lub minie timeout. Callback przekazany do niej może być wywołany wiele razy, więc powinien zawierać tylko asercje:
1import { render, screen, waitFor } from '@testing-library/react';
2
3test('loads mission data from API', async () => {
4 render(<MissionDetails missionId="apollo-13" />);
5
6 // Natychmiast widać spinner
7 expect(screen.getByText('Loading...')).toBeInTheDocument();
8
9 // Czekamy na dane
10 await waitFor(() => {
11 expect(screen.getByText('Apollo 13')).toBeInTheDocument();
12 });
13
14 // Spinner powinien zniknąć
15 expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
16});Kolejność asercji odtwarza życie komponentu: najpierw spinner, potem dane, na końcu spinner znika. Ostatnie sprawdzenie używa queryByText, bo getByText rzuciłby błąd, gdy elementu już nie ma.
Konfiguracja waitFor
Domyślne czasy można zmienić drugim argumentem:
1await waitFor(
2 () => {
3 expect(screen.getByText('Data loaded')).toBeInTheDocument();
4 },
5 {
6 timeout: 3000, // Maksymalny czas oczekiwania (domyślnie 1000ms)
7 interval: 100, // Jak często sprawdzać (domyślnie 50ms)
8 }
9);Zwiększaj timeout tylko wtedy, gdy operacja naprawdę trwa dłużej, bo długie czekanie spowalnia cały zestaw testów.
Zapytania findBy - Skrót do waitFor
Zapytania findBy to skrót łączący waitFor z getBy. Zwracają Promise, który spełnia się, gdy element się pojawi:
1// Te dwa zapisy są równoważne:
2
3// Sposób 1: waitFor + getBy
4await waitFor(() => {
5 expect(screen.getByText('Mission loaded')).toBeInTheDocument();
6});
7
8// Sposób 2: findBy (zalecany!)
9const element = await screen.findByText('Mission loaded');
10expect(element).toBeInTheDocument();Oba zapisy robią to samo, ale findBy jest krótszy i czytelniej mówi, na co czekamy. waitFor zostaw na sytuacje, w których czekasz na coś innego niż pojawienie się elementu, np. na wywołanie mocka.
Przykład z komponentem pobierającym dane
Oto typowy komponent, który po zamontowaniu pobiera listę załogi. Ma trzy stany: ładowanie, błąd i dane:
1function CrewList({ shipId }) {
2 const [crew, setCrew] = useState([]);
3 const [loading, setLoading] = useState(true);
4 const [error, setError] = useState(null);
5
6 useEffect(() => {
7 fetch(`/api/ships/${shipId}/crew`)
8 .then(res => res.json())
9 .then(data => {
10 setCrew(data);
11 setLoading(false);
12 })
13 .catch(err => {
14 setError(err.message);
15 setLoading(false);
16 });
17 }, [shipId]);
18
19 if (loading) return <p>Loading crew...</p>;
20 if (error) return <p>Error: {error}</p>;
21
22 return (
23 <ul>
24 {crew.map(member => (
25 <li key={member.id}>{member.name}</li>
26 ))}
27 </ul>
28 );
29}Efekt z fetch uruchamia się po pierwszym renderze, więc najpierw zawsze widać "Loading crew...". To właśnie ten przejściowy stan będziemy sprawdzać.
Testowanie tego komponentu
W teście nie chcemy prawdziwej sieci. Podmieniamy global.fetch na atrapę jest.fn() i ustalamy, co ma zwrócić:
1// Mockujemy fetch
2beforeEach(() => {
3 global.fetch = jest.fn();
4});
5
6test('displays crew members after loading', async () => {
7 fetch.mockResolvedValueOnce({
8 json: () => Promise.resolve([
9 { id: 1, name: 'Captain Nova' },
10 { id: 2, name: 'Engineer Bolt' },
11 ])
12 });
13
14 render(<CrewList shipId="enterprise" />);
15
16 // Stan ładowania
17 expect(screen.getByText('Loading crew...')).toBeInTheDocument();
18
19 // Czekamy na dane
20 expect(await screen.findByText('Captain Nova')).toBeInTheDocument();
21 expect(screen.getByText('Engineer Bolt')).toBeInTheDocument();
22 expect(screen.queryByText('Loading crew...')).not.toBeInTheDocument();
23});
24
25test('displays error when fetch fails', async () => {
26 fetch.mockRejectedValueOnce(new Error('Network error'));
27
28 render(<CrewList shipId="enterprise" />);
29
30 expect(await screen.findByText('Error: Network error')).toBeInTheDocument();
31});mockResolvedValueOnce zwraca obiekt udający odpowiedź z metodą json, a mockRejectedValueOnce symuluje awarię sieci. Pokrywamy więc obie ścieżki: sukces i błąd, bez jednego prawdziwego zapytania.
Gdy każde wywołanie fetch ma dostać tę samą odpowiedź, wystarczy jedna linia z mockResolvedValue, bez Once:
1const data = [{ id: 1, name: 'Captain Nova' }];
2global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve(data) });mockResolvedValue odpowiada tak samo przy każdym wywołaniu, a mockResolvedValueOnce tylko przy najbliższym. Wersję z Once wybieraj wtedy, gdy kolejne wywołania mają dostać różne odpowiedzi, na przykład najpierw błąd, a potem dane.
act() - Synchronizacja z Reactem
Funkcja act() informuje React, że wykonujemy operację, która aktualizuje stan i wymaga przerenderowania. Zobaczmy to na odliczaniu do startu. Komponent co sekundę zmniejsza licznik przez setInterval:
1function CountdownTimer({ seconds }) {
2 const [remaining, setRemaining] = useState(seconds);
3
4 useEffect(() => {
5 const id = setInterval(() => {
6 setRemaining(r => (r > 0 ? r - 1 : 0));
7 }, 1000);
8 return () => clearInterval(id);
9 }, []);
10
11 return <p>{remaining}</p>;
12}Dwa szczegóły mają znaczenie w testach. Aktualizacja funkcyjna setRemaining(r => ...) zawsze dostaje najświeższą wartość, choć funkcja w interwale powstała przy pierwszym renderze i sama widziałaby tylko początkowe remaining. Sprzątanie clearInterval zatrzymuje zegar, gdy komponent znika, a RTL odmontowuje komponent po każdym teście, więc interwał nie tyka w tle następnego testu.
Teraz test. Funkcję act importujemy z pakietu react, bo stary act z react-dom/test-utils jest w React 19 przestarzały:
1import { act } from 'react';
2
3test('updates timer display', () => {
4 jest.useFakeTimers();
5 render(<CountdownTimer seconds={10} />);
6
7 expect(screen.getByText('10')).toBeInTheDocument();
8
9 // Przyspieszamy czas o 3 sekundy
10 act(() => {
11 jest.advanceTimersByTime(3000);
12 });
13
14 expect(screen.getByText('7')).toBeInTheDocument();
15
16 jest.useRealTimers();
17});jest.useFakeTimers() podmienia zegar, a advanceTimersByTime(3000) przewija go o trzy sekundy bez czekania. Przesunięcie zegara zmienia stan poza RTL, dlatego owijamy je w act. Na końcu przywracamy prawdziwe timery, żeby nie zakłócić kolejnych testów.
Kiedy potrzebny jest act()?
- React Testing Library automatycznie opakowuje w
act()funkcjęrender, zdarzenia zfireEventiuserEventoraz czekanie wwaitForifindBy - Musisz użyć go ręcznie, gdy aktualizujesz stan poza narzędziami RTL (np. fake timers, bezpośrednie wywołanie callbacków)
Jeśli zobaczysz w konsoli ostrzeżenie "not wrapped in act(...)", to sygnał, że jakaś aktualizacja stanu nastąpiła po zakończeniu testu albo poza narzędziami RTL.
Testowanie operacji z debounce i throttle
Wyszukiwarka z opóźnieniem (debounce) nie wysyła zapytania po każdym klawiszu, tylko czeka, aż użytkownik przestanie pisać. Oto taki komponent. Każdy nowy znak zmienia query, a sprzątanie efektu kasuje poprzedni setTimeout, więc onSearch odpala się dopiero po 300 ms ciszy:
1function SearchBar({ onSearch, debounceMs = 300 }) {
2 const [query, setQuery] = useState('');
3
4 useEffect(() => {
5 if (!query) return;
6 const id = setTimeout(() => onSearch(query), debounceMs);
7 return () => clearTimeout(id);
8 }, [query, debounceMs, onSearch]);
9
10 return (
11 <input
12 aria-label="Szukaj misji"
13 value={query}
14 onChange={e => setQuery(e.target.value)}
15 />
16 );
17}Test łączy fake timery z userEvent. Opcja advanceTimers mówi bibliotece, jak przesuwać podmieniony zegar:
1test('calls onSearch once, after the user stops typing', async () => {
2 jest.useFakeTimers();
3 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
4 const onSearch = jest.fn();
5
6 render(<SearchBar onSearch={onSearch} debounceMs={300} />);
7
8 await user.type(screen.getByRole('textbox'), 'Mars');
9
10 // Przed upływem debounce: jeszcze żadnego zapytania
11 expect(onSearch).not.toHaveBeenCalled();
12
13 // Po upływie debounce
14 act(() => {
15 jest.advanceTimersByTime(300);
16 });
17
18 expect(onSearch).toHaveBeenCalledTimes(1);
19 expect(onSearch).toHaveBeenCalledWith('Mars');
20
21 jest.useRealTimers();
22});Bez opcji advanceTimers user.type czekałby na prawdziwe opóźnienia między klawiszami i test by się zawiesił. Po wpisaniu tekstu przewijamy czas o 300 ms, czyli dokładnie o długość debounce. Cztery klawisze, a jedno wywołanie z pełnym słowem Mars: właśnie tę obietnicę debounce sprawdza test. Throttle testuje się tak samo, tylko oczekujesz najwyżej jednego wywołania na każdy przedział czasu.
waitForElementToBeRemoved
Czekanie na zniknięcie elementu (np. spinnera). Funkcję importujesz z @testing-library/react, tak jak waitFor:
1test('hides loading spinner after data loads', async () => {
2 render(<DataPanel />);
3
4 // Spinner jest widoczny
5 const spinner = screen.getByText('Loading...');
6
7 // Czekamy aż zniknie
8 await waitForElementToBeRemoved(spinner);
9
10 // Dane powinny być widoczne
11 expect(screen.getByText('Data ready')).toBeInTheDocument();
12});Element trzeba znaleźć, zanim zniknie, dlatego getByText stoi przed waitForElementToBeRemoved. Funkcja czeka na usunięcie samego węzła DOM. Jeśli komponent tylko podmieni tekst w tym samym znaczniku, na przykład ten sam <p> pokaże najpierw "Loading...", a potem "Data ready", węzeł zostanie na miejscu i test skończy się przekroczeniem czasu. W takim przypadku czekaj na nowy tekst przez findByText.
Moja rada: wybieraj findBy, gdy czekasz na element, a waitFor tylko wtedy, gdy czekasz na coś innego. W następnej lekcji nauczysz się mockować całe moduły i API. Pamiętaj: test asynchroniczny to cierpliwy radar, który nasłuchuje sygnału, ale ma ustalony limit czasu.
Kod do tej lekcji: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Symulowane API: podgląd nie ma serwera, więc odpowiedź przychodzi
4// z Promise i setTimeout, tak jak z mocka w testach z lekcji
5const MISSIONS = [
6 { id: 1, name: 'Apollo 11', status: 'completed' },
7 { id: 2, name: 'Artemis I', status: 'active' },
8 { id: 3, name: 'Mars 2030', status: 'planned' },
9];
10
11const STATUS_LABELS = { completed: 'zakończona', active: 'aktywna', planned: 'planowana' };
12
13function fakeApi(endpoint, delay = 1500) {
14 return new Promise((resolve, reject) => {
15 setTimeout(() => {
16 if (endpoint === '/api/missions') {
17 resolve(MISSIONS);
18 } else {
19 reject(new Error('utracono łączność z centrum kontroli'));
20 }
21 }, delay);
22 });
23}
24
25// Komponent asynchroniczny: ładowanie, potem dane albo błąd
26function MissionLoader({ endpoint }) {
27 const [data, setData] = useState(null);
28 const [error, setError] = useState(null);
29 const [attempt, setAttempt] = useState(0);
30
31 useEffect(() => {
32 let ignore = false;
33 fakeApi(endpoint)
34 .then(result => {
35 if (!ignore) setData(result);
36 })
37 .catch(err => {
38 if (!ignore) setError(err.message);
39 });
40 return () => {
41 ignore = true;
42 };
43 }, [endpoint, attempt]);
44
45 if (error) {
46 return (
47 <div className="error">
48 <p role="alert">Sygnał utracony: {error}</p>
49 <button
50 onClick={() => {
51 setError(null);
52 setAttempt(a => a + 1);
53 }}
54 >
55 Ponów połączenie
56 </button>
57 </div>
58 );
59 }
60
61 if (!data) {
62 return (
63 <div className="loading" role="status">
64 <div className="spinner" />
65 <p>Odbieram sygnał z głębokiego kosmosu...</p>
66 </div>
67 );
68 }
69
70 return (
71 <div className="mission-data">
72 <h3>Odebrane misje ({data.length})</h3>
73 <ul>
74 {data.map(mission => (
75 <li key={mission.id} className={`mission-${mission.status}`}>
76 <strong>{mission.name}</strong>
77 <span className="status-badge">{STATUS_LABELS[mission.status]}</span>
78 </li>
79 ))}
80 </ul>
81 </div>
82 );
83}
84
85// Wyszukiwarka z debounce: zapytanie wychodzi dopiero 300 ms po ostatnim klawiszu
86function DebouncedSearch({ debounceMs = 300 }) {
87 const [query, setQuery] = useState('');
88 const [sent, setSent] = useState([]);
89
90 useEffect(() => {
91 if (!query) return;
92 const id = setTimeout(() => {
93 setSent(prev => [...prev, query]);
94 }, debounceMs);
95 return () => clearTimeout(id);
96 }, [query, debounceMs]);
97
98 return (
99 <div className="search">
100 <h3>Wyszukiwarka z opóźnieniem</h3>
101 <label htmlFor="mission-search">Szukaj misji</label>
102 <input
103 id="mission-search"
104 value={query}
105 onChange={e => setQuery(e.target.value)}
106 placeholder="Wpisz szybko: Mars"
107 />
108 <p>Wysłane zapytania: {sent.length}</p>
109 {sent.length > 0 && <p className="last-query">Ostatnie zapytanie: {sent[sent.length - 1]}</p>}
110 </div>
111 );
112}
113
114// Odliczanie: setInterval, aktualizacja funkcyjna i sprzątanie przez clearInterval
115function CountdownTimer({ seconds }) {
116 const [remaining, setRemaining] = useState(seconds);
117 const [running, setRunning] = useState(false);
118 const counting = running && remaining > 0;
119
120 useEffect(() => {
121 if (!counting) return;
122 const id = setInterval(() => {
123 setRemaining(r => Math.max(r - 1, 0));
124 }, 1000);
125 return () => clearInterval(id);
126 }, [counting]);
127
128 return (
129 <div className="countdown">
130 <h3>Odliczanie do startu</h3>
131 <div className="timer-display">
132 {remaining > 0 ? (
133 <span className="time">{remaining}</span>
134 ) : (
135 <span className="launched">Start!</span>
136 )}
137 </div>
138 <div className="btn-row">
139 <button onClick={() => setRunning(true)} disabled={counting || remaining === 0}>
140 {counting ? 'Odliczam...' : 'Rozpocznij odliczanie'}
141 </button>
142 <button
143 onClick={() => {
144 setRunning(false);
145 setRemaining(seconds);
146 }}
147 >
148 Resetuj
149 </button>
150 </div>
151 </div>
152 );
153}
154
155export default function App() {
156 const [endpoint, setEndpoint] = useState('/api/missions');
157
158 return (
159 <div className="app">
160 <h1>Testowanie asynchroniczne - sygnały z kosmosu</h1>
161 <p className="note">
162 Podgląd pokazuje działające komponenty z lekcji. Testy z fake timerami i findBy
163 uruchamiasz w Jest, w swoim projekcie.
164 </p>
165
166 <div className="controls">
167 <button
168 onClick={() => setEndpoint('/api/missions')}
169 className={endpoint === '/api/missions' ? 'active' : ''}
170 aria-pressed={endpoint === '/api/missions'}
171 >
172 Pobierz misje
173 </button>
174 <button
175 onClick={() => setEndpoint('/api/error')}
176 className={endpoint === '/api/error' ? 'active' : ''}
177 aria-pressed={endpoint === '/api/error'}
178 >
179 Symuluj błąd
180 </button>
181 </div>
182
183 {/* key: po zmianie źródła komponent startuje od nowa, od stanu ładowania */}
184 <MissionLoader key={endpoint} endpoint={endpoint} />
185 <DebouncedSearch />
186 <CountdownTimer seconds={10} />
187 </div>
188 );
189}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 funkcja waitFor w React Testing Library?
2. Kiedy musisz ręcznie użyć act() w testach React?
Zadania praktyczne w grze
- Edytor kodu
Dokończ wyszukiwarkę SearchFilter w atlasie obiektów kosmicznych. ___BLANK1___: obiekt pasuje do wyszukiwania, gdy jego nazwa zawiera wpisany tekst, przy czym wielkość liter ani spacje na początku i końcu wpisanego tekstu nie mają znaczenia (pusty tekst pasuje do wszystkiego). ___BLANK2___: obiekt pasuje do kategorii, gdy wybrano Wszystkie (wartość 'all') albo gdy jego kategoria jest równa wybranej. ___BLANK3___: gdy po filtrowaniu nie zostaje żaden obiekt, zamiast listy widać komunikat z role="status". Test wpisuje tekst w pole wyszukiwania (rola searchbox), wybiera kategorię z listy i sprawdza elementy listy.
- Układanie w poziomie
Ułóż składnię mockowania globalnego fetch w teście:
- Układanie w pionie
Ułóż etapy testowania komponentu pobierającego dane z API:
- Edytor kodu
Dokończ CountdownTimer, który po kliknięciu Start odlicza sekundy do startu rakiety. ___BLANK1___: co sekundę funkcja aktualizująca zmniejsza aktualną wartość o 1 (użyj parametru current, a nie zmiennej left, bo interwał tworzony jest raz i widziałby starą wartość). ___BLANK2___: efekt zwraca funkcję sprzątającą, która wywołuje clearInterval(id), żeby Pauza, Reset i odmontowanie zatrzymywały interwał. ___BLANK3___: elapsed to liczba sekund, które już minęły (seconds minus left); pasek postępu pokazuje ją w aria-valuenow. Test włącza fake timery i przewija czas o kolejne sekundy.