Custom hooki 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.renderHook pozwala przetestować hook bez tworzenia komponentu: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});Zmiany stanu hooka muszą być opakowane w
act():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}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});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}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});Gdy hook zależy od propsów, używamy
rerender: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});Hooki używające kontekstu wymagają wrappera:
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}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});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}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});