Kurs JavaScript i React · Moduł 13: Testowanie React
Testowanie Hooków - Diagnostyka Systemów Wewnętrznych
W tej lekcji7
Własne hooki (custom hooks) to jak wewnętrzne systemy statku kosmicznego - silnik grawitacyjny, system podtrzymywania życia, nawigacja. Nie widzisz ich bezpośrednio, ale musisz mieć pewność, że działają poprawnie. Do testowania hooków używamy renderHook z React Testing Library.
Hooka nie da się po prostu wywołać w teście jak zwykłej funkcji, bo React pozwala na wywołanie hooków tylko podczas renderu komponentu. Mógłbyś pisać sztuczny komponent do każdego testu, ale to sporo powtarzalnego kodu, który niczego nie sprawdza.
renderHook - Uruchomienie Hooka w Izolacji
renderHook pozwala przetestować hook bez pisania własnego komponentu: biblioteka sama tworzy mały komponent testowy i wywołuje w nim Twój hook. Funkcja jest częścią @testing-library/react od wersji 13.1, a osobny pakiet @testing-library/react-hooks jest przestarzały:
1import { renderHook, act } from '@testing-library/react';
2import { useCounter } from './useCounter';
3
4test('initializes counter with default value', () => {
5 const { result } = renderHook(() => useCounter());
6
7 expect(result.current.count).toBe(0);
8});
9
10test('initializes counter with custom value', () => {
11 const { result } = renderHook(() => useCounter(10));
12
13 expect(result.current.count).toBe(10);
14});Wynik hooka czytamy z result.current. Właściwość current aktualizuje się po każdym renderze, dlatego nie rozpakowuj jej do zmiennej na początku testu, bo zostałaby Ci stara wartość.
Poza result funkcja zwraca też rerender i unmount. Pierwszą poznasz za chwilę, a druga odmontowuje komponent testowy, więc pozwala sprawdzić, czy efekt hooka po sobie sprząta, np. zamyka połączenie albo czyści interwał.
Testowanie zmian stanu
Zmiany stanu hooka muszą być opakowane w act(). Oto hook licznika, który będziemy testować:
1// useCounter.js
2function useCounter(initialValue = 0) {
3 const [count, setCount] = useState(initialValue);
4
5 const increment = () => setCount(c => c + 1);
6 const decrement = () => setCount(c => c - 1);
7 const reset = () => setCount(initialValue);
8
9 return { count, increment, decrement, reset };
10}Hook zwraca obiekt z wartością count i trzema funkcjami. Zwróć uwagę, że reset wraca do initialValue, a nie do zera. Teraz test:
1test('increments and decrements counter', () => {
2 const { result } = renderHook(() => useCounter(5));
3
4 expect(result.current.count).toBe(5);
5
6 act(() => {
7 result.current.increment();
8 });
9 expect(result.current.count).toBe(6);
10
11 act(() => {
12 result.current.decrement();
13 });
14 expect(result.current.count).toBe(5);
15
16 act(() => {
17 result.current.reset();
18 });
19 expect(result.current.count).toBe(5);
20});Wywołujemy funkcje hooka wewnątrz act, bo zmieniają stan poza narzędziami RTL. Ostatnia asercja potwierdza, że reset przywraca wartość startową 5.
Hook, który zwraca tablicę
Nie każdy hook zwraca obiekt. useToggle naśladuje useState i oddaje parę [wartość, przełącz], którą komponent rozpakuje pod dowolnymi nazwami, np. const [shieldsOn, toggleShields] = useToggle():
1function useToggle(initialValue = false) {
2 const [value, setValue] = useState(initialValue);
3 const toggle = useCallback(() => setValue(v => !v), []);
4 return [value, toggle];
5}Przełącznik używa aktualizacji funkcyjnej v => !v. Gdyby napisać setValue(!value), dwa wywołania w jednym act zobaczyłyby tę samą starą wartość i drugie nie cofnęłoby pierwszego. W teście sięgasz do elementów tablicy przez indeksy:
1test('toggles shields on and off', () => {
2 const { result } = renderHook(() => useToggle(false));
3
4 expect(result.current[0]).toBe(false);
5
6 act(() => {
7 result.current[1]();
8 });
9 expect(result.current[0]).toBe(true);
10
11 act(() => {
12 result.current[1]();
13 });
14 expect(result.current[0]).toBe(false);
15});result.current[0] to wartość, a result.current[1] to funkcja przełączająca. Nie rozpakowuj ich na początku testu przez const [value, toggle] = result.current, bo value zostałoby starą kopią, tak jak przy obiekcie. Tak samo przetestujesz hook listy, który zwraca np. { items, add, remove, clear }: wywołujesz akcję w act i sprawdzasz result.current.items.
Testowanie hooków z efektami
Hook paliwa pobiera dane w useEffect, więc wynik pojawia się asynchronicznie:
1// useFuelGauge.js
2function useFuelGauge(shipId) {
3 const [fuel, setFuel] = useState(null);
4 const [loading, setLoading] = useState(true);
5
6 useEffect(() => {
7 fetch(`/api/ships/${shipId}/fuel`)
8 .then(res => res.json())
9 .then(data => {
10 setFuel(data.level);
11 setLoading(false);
12 });
13 }, [shipId]);
14
15 return { fuel, loading };
16}Stan loading zaczyna od true i zmienia się dopiero po odpowiedzi serwera. Test musi więc poczekać na zmianę:
1test('fetches fuel level for ship', async () => {
2 global.fetch = jest.fn().mockResolvedValue({
3 json: () => Promise.resolve({ level: 85 })
4 });
5
6 const { result } = renderHook(() => useFuelGauge('enterprise'));
7
8 // Początkowo loading
9 expect(result.current.loading).toBe(true);
10 expect(result.current.fuel).toBeNull();
11
12 // Po załadowaniu
13 await waitFor(() => {
14 expect(result.current.loading).toBe(false);
15 });
16 expect(result.current.fuel).toBe(85);
17});waitFor powtarza asercję, aż loading stanie się false. Po tym momencie możemy bezpiecznie sprawdzić poziom paliwa. Atrapa global.fetch sprawia, że test nie wychodzi do sieci i zawsze dostaje ten sam poziom 85, więc wynik jest powtarzalny.
Testowanie hooków z propami zmiennymi
Gdy hook zależy od propsów, używamy rerender. Opcja initialProps podaje argumenty pierwszego renderu:
1test('refetches fuel when shipId changes', async () => {
2 global.fetch = jest.fn()
3 .mockResolvedValueOnce({
4 json: () => Promise.resolve({ level: 85 })
5 })
6 .mockResolvedValueOnce({
7 json: () => Promise.resolve({ level: 42 })
8 });
9
10 const { result, rerender } = renderHook(
11 ({ shipId }) => useFuelGauge(shipId),
12 { initialProps: { shipId: 'enterprise' } }
13 );
14
15 await waitFor(() => {
16 expect(result.current.fuel).toBe(85);
17 });
18
19 // Zmiana shipId -- rerender z nowymi propsami
20 rerender({ shipId: 'discovery' });
21
22 await waitFor(() => {
23 expect(result.current.fuel).toBe(42);
24 });
25});Dwa mockResolvedValueOnce ustawiają odpowiedzi w kolejności wywołań. rerender z nowym shipId uruchamia efekt ponownie, a hook pobiera paliwo drugiego statku.
Testowanie hooków z kontekstem
Hooki używające kontekstu wymagają wrappera. Oto hook, który rzuca błąd, gdy brakuje dostawcy:
1// useTheme.js
2const ThemeContext = createContext();
3
4function useTheme() {
5 const context = useContext(ThemeContext);
6 if (!context) throw new Error('useTheme must be used within ThemeProvider');
7 return context;
8}Taki wyjątek to dobra praktyka: programista od razu wie, że zapomniał o dostawcy. Test sprawdza obie ścieżki:
1test('returns theme from context', () => {
2 const wrapper = ({ children }) => (
3 <ThemeContext.Provider value={{ mode: 'dark', color: 'blue' }}>
4 {children}
5 </ThemeContext.Provider>
6 );
7
8 const { result } = renderHook(() => useTheme(), { wrapper });
9
10 expect(result.current.mode).toBe('dark');
11 expect(result.current.color).toBe('blue');
12});
13
14test('throws error without provider', () => {
15 expect(() => {
16 renderHook(() => useTheme());
17 }).toThrow('useTheme must be used within ThemeProvider');
18});Opcja wrapper owija komponent testowy w dostawcę. W React 19 zamiast ThemeContext.Provider możesz pisać krótko <ThemeContext value={...}>. W drugim teście błąd rzucony podczas renderu wraca z renderHook jako zwykły wyjątek, więc toThrow go łapie. W React 18 taki test dodatkowo zaśmiecał konsolę komunikatem o błędzie i wyciszało się go przez jest.spyOn(console, 'error'). W React 19 konsola zostaje czysta, więc szpieg nie jest potrzebny.
Testowanie hooka useReducer
Hook oparty na useReducer trzyma logikę w czystej funkcji reducera:
1// useMissionControl.js
2function missionReducer(state, action) {
3 switch (action.type) {
4 case 'START_MISSION':
5 return { ...state, status: 'active', mission: action.payload };
6 case 'ABORT_MISSION':
7 return { ...state, status: 'aborted', mission: null };
8 case 'COMPLETE_MISSION':
9 return { ...state, status: 'completed' };
10 default:
11 return state;
12 }
13}
14
15function useMissionControl() {
16 const [state, dispatch] = useReducer(missionReducer, {
17 status: 'idle',
18 mission: null
19 });
20
21 const startMission = (name) => dispatch({ type: 'START_MISSION', payload: name });
22 const abortMission = () => dispatch({ type: 'ABORT_MISSION' });
23 const completeMission = () => dispatch({ type: 'COMPLETE_MISSION' });
24
25 return { ...state, startMission, abortMission, completeMission };
26}Reducer przyjmuje stan i akcję, a zwraca nowy stan. Hook opakowuje dispatch w czytelne funkcje, jak startMission. Test przechodzi przez cały cykl misji:
1test('manages mission lifecycle', () => {
2 const { result } = renderHook(() => useMissionControl());
3
4 // Stan początkowy
5 expect(result.current.status).toBe('idle');
6 expect(result.current.mission).toBeNull();
7
8 // Start misji
9 act(() => result.current.startMission('Apollo'));
10 expect(result.current.status).toBe('active');
11 expect(result.current.mission).toBe('Apollo');
12
13 // Zakończenie misji
14 act(() => result.current.completeMission());
15 expect(result.current.status).toBe('completed');
16});Reducer jest czystą funkcją, więc możesz go też testować osobno, bez renderHook: podajesz stan i akcję, a sprawdzasz wynik.
W kolejnej lekcji zrobisz "zdjęcie" wyrenderowanego interfejsu i poznasz snapshot testing.
Moja rada: testuj hook przez renderHook tylko wtedy, gdy jest używany w wielu komponentach, a w pozostałych przypadkach testuj komponent, który z niego korzysta. Pamiętaj: renderHook to stanowisko diagnostyczne, na którym sprawdzasz system statku, zanim zamontujesz go w kokpicie.
Kod do tej lekcji: App.jsx
1import React, { useState, useReducer, useCallback } from 'react';
2
3// ===== Własne hooki (w testach uruchamiasz je przez renderHook) =====
4
5function useCounter(initialValue = 0) {
6 const [count, setCount] = useState(initialValue);
7 const increment = useCallback(() => setCount(c => c + 1), []);
8 const decrement = useCallback(() => setCount(c => c - 1), []);
9 const reset = useCallback(() => setCount(initialValue), [initialValue]);
10 return { count, increment, decrement, reset };
11}
12
13// Hook zwracający tablicę [wartość, przełącz], jak useState
14function useToggle(initialValue = false) {
15 const [value, setValue] = useState(initialValue);
16 const toggle = useCallback(() => setValue(v => !v), []);
17 return [value, toggle];
18}
19
20function missionReducer(state, action) {
21 switch (action.type) {
22 case 'START_MISSION':
23 return { ...state, status: 'active', mission: action.payload };
24 case 'ABORT_MISSION':
25 return { ...state, status: 'aborted', mission: null };
26 case 'COMPLETE_MISSION':
27 return { ...state, status: 'completed' };
28 default:
29 return state;
30 }
31}
32
33function useMissionControl() {
34 const [state, dispatch] = useReducer(missionReducer, {
35 status: 'idle',
36 mission: null,
37 });
38 const startMission = (name) => dispatch({ type: 'START_MISSION', payload: name });
39 const abortMission = () => dispatch({ type: 'ABORT_MISSION' });
40 const completeMission = () => dispatch({ type: 'COMPLETE_MISSION' });
41 return { ...state, startMission, abortMission, completeMission };
42}
43
44// ===== Komponenty demonstracyjne =====
45
46const STATUS_LABELS = {
47 idle: 'oczekuje',
48 active: 'w toku',
49 aborted: 'przerwana',
50 completed: 'zakończona',
51};
52
53function CounterDemo() {
54 const { count, increment, decrement, reset } = useCounter(0);
55 return (
56 <div className="demo-card">
57 <h3>Hook useCounter</h3>
58 <div className="counter-display">{count}</div>
59 <div className="btn-group">
60 <button onClick={decrement}>-1</button>
61 <button onClick={reset}>Resetuj</button>
62 <button onClick={increment}>+1</button>
63 </div>
64 </div>
65 );
66}
67
68function ToggleDemo() {
69 const [engineOn, toggleEngine] = useToggle(false);
70 const [shieldsOn, toggleShields] = useToggle(true);
71 return (
72 <div className="demo-card">
73 <h3>Hook useToggle</h3>
74 <div className="toggle-row">
75 <span>Silnik: {engineOn ? 'WŁĄCZONY' : 'WYŁĄCZONY'}</span>
76 <button onClick={toggleEngine} aria-pressed={engineOn}>Przełącz silnik</button>
77 </div>
78 <div className="toggle-row">
79 <span>Osłony: {shieldsOn ? 'WŁĄCZONE' : 'WYŁĄCZONE'}</span>
80 <button onClick={toggleShields} aria-pressed={shieldsOn}>Przełącz osłony</button>
81 </div>
82 </div>
83 );
84}
85
86function MissionDemo() {
87 const mission = useMissionControl();
88 const [input, setInput] = useState('');
89 return (
90 <div className="demo-card">
91 <h3>Hook useMissionControl</h3>
92 <p>Status: <strong>{STATUS_LABELS[mission.status]}</strong></p>
93 {mission.mission && <p>Misja: {mission.mission}</p>}
94 {mission.status !== 'active' && (
95 <div className="mission-form">
96 <input
97 value={input}
98 onChange={e => setInput(e.target.value)}
99 placeholder="Nazwa misji..."
100 aria-label="Nazwa misji"
101 />
102 <button
103 disabled={!input.trim()}
104 onClick={() => {
105 mission.startMission(input.trim());
106 setInput('');
107 }}
108 >
109 Rozpocznij
110 </button>
111 </div>
112 )}
113 {mission.status === 'active' && (
114 <div className="btn-group">
115 <button onClick={mission.abortMission} className="danger">Przerwij</button>
116 <button onClick={mission.completeMission} className="success">Zakończ</button>
117 </div>
118 )}
119 </div>
120 );
121}
122
123export default function App() {
124 return (
125 <div className="app">
126 <h1>Testowanie hooków</h1>
127 <p className="subtitle">renderHook + act() = diagnostyka systemów statku</p>
128 <p className="note">
129 Podgląd pokazuje hooki w działaniu. Te same hooki sprawdzasz w Jest
130 przez renderHook, bez klikania.
131 </p>
132 <CounterDemo />
133 <ToggleDemo />
134 <MissionDemo />
135 </div>
136 );
137}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 renderHook z React Testing Library?
Zadania praktyczne w grze
- Edytor kodu
Dokończ dwa własne hooki ładowni statku. useToggle(initialValue) zwraca [value, toggle], a useList(initialItems) zwraca { items, add, remove, clear }. ___BLANK1___: toggle przełącza wartość na przeciwną funkcją aktualizującą (z parametrem current), bo toggle jest zapamiętane przez useCallback i nie widzi nowego value. ___BLANK2___: add dopisuje element na końcu nowej tablicy. ___BLANK3___: remove(index) zwraca nową tablicę bez elementu o podanym indeksie. Nie zmieniaj tablicy current (push, splice): test zamraża tablicę początkową. Test woła hooki przez renderHook, a akcje przez act, także dwie pod rząd w jednym act.
- Układanie w pionie
Ułóż etapy testowania custom hooka z renderHook:
- Edytor kodu
Dokończ zakładki konsoli dowódcy zgodne z ARIA. ___BLANK1___: każdy przycisk zakładki ma rolę tab (kontener ma już role="tablist", a treść role="tabpanel"), więc test znajdzie zakładki przez getAllByRole('tab'). ___BLANK2___: isActive jest prawdą tylko dla zakładki, której id jest równe activeId; trafia do aria-selected i do stylu. ___BLANK3___: kliknięcie zakładki zapisuje w stanie jej id. Test sprawdza, że na starcie wybrana jest pierwsza zakładka, że po kliknięciu wybór się przenosi, a panel pokazuje treść wybranej zakładki i ma jej nazwę (aria-labelledby).
- Klikanie w kolejności
Ułóż składnię wywołania renderHook dla custom hooka:
- Układanie w poziomie
Ułóż składnię sprawdzenia wartości hooka w teście:
- Edytor kodu
Dokończ centrum powiadomień misji AlertSystem. Przyciski dodają powiadomienia trzech typów (info, warning, danger), każde z przyciskiem zamknięcia, a Wyczyść wszystkie usuwa całą listę. ___BLANK1___: nowe powiadomienie trafia na koniec listy w nowej tablicy (wcześniejsze zostają). ___BLANK2___: removeAlert(id) zwraca nową tablicę bez powiadomienia o podanym id, a pozostałe zostają, także te tego samego typu. ___BLANK3___: alarm (typ danger) ma rolę alert, a info i ostrzeżenie rolę status. Test klika przyciski, liczy elementy z rolami alert i status i zamyka wybrane powiadomienie jego przyciskiem.