Testing & TDD

Testing React Hooks & Context

32 min Lesson 12 of 35

Introduction to Testing Hooks and Context

Testing React Hooks and Context requires special techniques because they can't be called outside of React components. React Testing Library provides utilities like renderHook for testing custom hooks in isolation and strategies for testing Context providers effectively.

Why Test Hooks Separately?

Custom hooks often contain reusable business logic that should be tested independently:

  • Isolation: Test hook logic without component rendering overhead
  • Reusability: Ensure hooks work correctly in different contexts
  • Debugging: Pinpoint issues in hook logic vs component rendering
  • Documentation: Tests serve as usage examples for your hooks

Setting Up Hook Testing

Install the necessary package:

npm install --save-dev @testing-library/react-hooks # For React 18+ (renderHook is built into @testing-library/react) # No additional package needed
Note: As of React 18, renderHook is included in @testing-library/react. For React 16-17, use @testing-library/react-hooks.

Testing Simple Custom Hooks

Let's test a simple counter hook:

// useCounter.js import { useState } from 'react'; export function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue); const increment = () => setCount(c => c + 1); const decrement = () => setCount(c => c - 1); const reset = () => setCount(initialValue); return { count, increment, decrement, reset }; }

Test the hook:

// useCounter.test.js import { renderHook, act } from '@testing-library/react'; import { useCounter } from './useCounter'; test('initializes with default value', () => { const { result } = renderHook(() => useCounter()); expect(result.current.count).toBe(0); }); test('initializes with custom value', () => { const { result } = renderHook(() => useCounter(10)); expect(result.current.count).toBe(10); }); test('increments count', () => { const { result } = renderHook(() => useCounter()); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); }); test('decrements count', () => { const { result } = renderHook(() => useCounter(5)); act(() => { result.current.decrement(); }); expect(result.current.count).toBe(4); }); test('resets to initial value', () => { const { result } = renderHook(() => useCounter(10)); act(() => { result.current.increment(); result.current.increment(); result.current.reset(); }); expect(result.current.count).toBe(10); });
Tip: Always wrap state updates in act(). This ensures React processes updates before assertions, matching real component behavior.

Understanding renderHook

The renderHook function returns an object with useful properties:

const { result, rerender, unmount } = renderHook(() => useMyHook()); // result.current - The current return value of the hook console.log(result.current); // rerender() - Re-run the hook with new props rerender(); // unmount() - Unmount the hook (cleanup) unmount();

Testing Hooks with Props

Test hooks that accept parameters by passing them through renderHook:

// useFetch.js import { useState, useEffect } from 'react'; export function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; fetch(url) .then(res => res.json()) .then(data => { if (!cancelled) { setData(data); setLoading(false); } }) .catch(err => { if (!cancelled) { setError(err); setLoading(false); } }); return () => { cancelled = true; }; }, [url]); return { data, loading, error }; }

Test with different URLs:

// useFetch.test.js import { renderHook, waitFor } from '@testing-library/react'; import { useFetch } from './useFetch'; beforeEach(() => { global.fetch = jest.fn(); }); afterEach(() => { jest.restoreAllMocks(); }); test('fetches data successfully', async () => { const mockData = { id: 1, name: 'Test' }; global.fetch.mockResolvedValueOnce({ json: async () => mockData }); const { result } = renderHook(() => useFetch('/api/users/1')); // Initially loading expect(result.current.loading).toBe(true); expect(result.current.data).toBe(null); // Wait for fetch to complete await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.data).toEqual(mockData); expect(result.current.error).toBe(null); }); test('handles fetch errors', async () => { const mockError = new Error('Network error'); global.fetch.mockRejectedValueOnce(mockError); const { result } = renderHook(() => useFetch('/api/users/1')); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.error).toBe(mockError); expect(result.current.data).toBe(null); }); test('refetches when URL changes', async () => { const mockData1 = { id: 1, name: 'User 1' }; const mockData2 = { id: 2, name: 'User 2' }; global.fetch .mockResolvedValueOnce({ json: async () => mockData1 }) .mockResolvedValueOnce({ json: async () => mockData2 }); const { result, rerender } = renderHook( ({ url }) => useFetch(url), { initialProps: { url: '/api/users/1' } } ); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.data).toEqual(mockData1); // Change URL rerender({ url: '/api/users/2' }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.data).toEqual(mockData2); });

Testing Hooks with Dependencies

Test hooks that depend on other hooks or external libraries:

// useLocalStorage.js import { useState, useEffect } from 'react'; export function useLocalStorage(key, initialValue) { const [value, setValue] = useState(() => { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : initialValue; }); useEffect(() => { localStorage.setItem(key, JSON.stringify(value)); }, [key, value]); return [value, setValue]; }

Mock localStorage for testing:

// useLocalStorage.test.js import { renderHook, act } from '@testing-library/react'; import { useLocalStorage } from './useLocalStorage'; let localStorageMock; beforeEach(() => { localStorageMock = { getItem: jest.fn(), setItem: jest.fn(), clear: jest.fn() }; global.localStorage = localStorageMock; }); test('reads initial value from localStorage', () => { localStorageMock.getItem.mockReturnValue(JSON.stringify('stored value')); const { result } = renderHook(() => useLocalStorage('key', 'default')); expect(result.current[0]).toBe('stored value'); expect(localStorageMock.getItem).toHaveBeenCalledWith('key'); }); test('uses default value when localStorage is empty', () => { localStorageMock.getItem.mockReturnValue(null); const { result } = renderHook(() => useLocalStorage('key', 'default')); expect(result.current[0]).toBe('default'); }); test('updates localStorage when value changes', () => { localStorageMock.getItem.mockReturnValue(null); const { result } = renderHook(() => useLocalStorage('key', 'default')); act(() => { result.current[1]('new value'); }); expect(localStorageMock.setItem).toHaveBeenCalledWith( 'key', JSON.stringify('new value') ); });

Testing Context Providers

Context requires wrapping components/hooks with providers. Create a custom wrapper:

// AuthContext.js import { createContext, useContext, useState } from 'react'; const AuthContext = createContext(); export function AuthProvider({ children }) { const [user, setUser] = useState(null); const login = (userData) => setUser(userData); const logout = () => setUser(null); return ( <AuthContext.Provider value={{ user, login, logout }}> {children} </AuthContext.Provider> ); } export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within AuthProvider'); } return context; }

Test the context and hook:

// AuthContext.test.js import { renderHook, act } from '@testing-library/react'; import { AuthProvider, useAuth } from './AuthContext'; test('provides authentication context', () => { const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); expect(result.current.user).toBe(null); }); test('logs in user', () => { const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); const userData = { id: 1, name: 'John Doe' }; act(() => { result.current.login(userData); }); expect(result.current.user).toEqual(userData); }); test('logs out user', () => { const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); act(() => { result.current.login({ id: 1, name: 'John' }); result.current.logout(); }); expect(result.current.user).toBe(null); }); test('throws error when used outside provider', () => { // Suppress console.error for this test const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => { renderHook(() => useAuth()); }).toThrow('useAuth must be used within AuthProvider'); spy.mockRestore(); });

Testing Components with Context

Test components that consume context by wrapping them with providers:

// UserProfile.test.js import { render, screen } from '@testing-library/react'; import { AuthProvider } from './AuthContext'; import UserProfile from './UserProfile'; function renderWithAuth(ui, { user = null, ...options } = {}) { function Wrapper({ children }) { return ( <AuthProvider initialUser={user}> {children} </AuthProvider> ); } return render(ui, { wrapper: Wrapper, ...options }); } test('shows login prompt when not authenticated', () => { renderWithAuth(<UserProfile />); expect(screen.getByText(/please log in/i)).toBeInTheDocument(); }); test('shows user info when authenticated', () => { const user = { id: 1, name: 'John Doe', email: 'john@example.com' }; renderWithAuth(<UserProfile />, { user }); expect(screen.getByText(/john doe/i)).toBeInTheDocument(); expect(screen.getByText(/john@example.com/i)).toBeInTheDocument(); });
Tip: Create reusable render functions with common providers. This reduces boilerplate and ensures consistent test setup.

Testing Context with Multiple Providers

Nest multiple providers for complex scenarios:

function AllProviders({ children }) { return ( <AuthProvider> <ThemeProvider> <I18nProvider> {children} </I18nProvider> </ThemeProvider> </AuthProvider> ); } test('component with multiple contexts', () => { const { result } = renderHook( () => ({ auth: useAuth(), theme: useTheme(), i18n: useI18n() }), { wrapper: AllProviders } ); expect(result.current.auth.user).toBe(null); expect(result.current.theme.mode).toBe('light'); expect(result.current.i18n.locale).toBe('en'); });

Testing Async Hooks

Test hooks with asynchronous operations:

// useDebounce.js import { useState, useEffect } from 'react'; export function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; }

Test with fake timers:

// useDebounce.test.js import { renderHook } from '@testing-library/react'; import { useDebounce } from './useDebounce'; beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.runOnlyPendingTimers(); jest.useRealTimers(); }); test('returns initial value immediately', () => { const { result } = renderHook(() => useDebounce('initial', 500)); expect(result.current).toBe('initial'); }); test('debounces value changes', () => { const { result, rerender } = renderHook( ({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 500 } } ); expect(result.current).toBe('initial'); // Change value rerender({ value: 'changed', delay: 500 }); // Value shouldn't change immediately expect(result.current).toBe('initial'); // Fast-forward time jest.advanceTimersByTime(500); // Now value should update expect(result.current).toBe('changed'); }); test('cancels previous timeout on rapid changes', () => { const { result, rerender } = renderHook( ({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'first', delay: 500 } } ); rerender({ value: 'second', delay: 500 }); jest.advanceTimersByTime(250); rerender({ value: 'third', delay: 500 }); jest.advanceTimersByTime(500); // Should skip 'second' and go straight to 'third' expect(result.current).toBe('third'); });
Note: Use jest.useFakeTimers() to control time in tests. Remember to restore real timers after each test.

Testing Hook Cleanup

Verify that hooks properly clean up side effects:

// useEventListener.js import { useEffect } from 'react'; export function useEventListener(event, handler, element = window) { useEffect(() => { element.addEventListener(event, handler); return () => { element.removeEventListener(event, handler); }; }, [event, handler, element]); }

Test cleanup:

// useEventListener.test.js import { renderHook } from '@testing-library/react'; import { useEventListener } from './useEventListener'; test('adds event listener', () => { const handler = jest.fn(); const addSpy = jest.spyOn(window, 'addEventListener'); renderHook(() => useEventListener('resize', handler)); expect(addSpy).toHaveBeenCalledWith('resize', handler); addSpy.mockRestore(); }); test('removes event listener on unmount', () => { const handler = jest.fn(); const removeSpy = jest.spyOn(window, 'removeEventListener'); const { unmount } = renderHook(() => useEventListener('resize', handler)); unmount(); expect(removeSpy).toHaveBeenCalledWith('resize', handler); removeSpy.mockRestore(); }); test('updates listener when handler changes', () => { const handler1 = jest.fn(); const handler2 = jest.fn(); const addSpy = jest.spyOn(window, 'addEventListener'); const removeSpy = jest.spyOn(window, 'removeEventListener'); const { rerender } = renderHook( ({ handler }) => useEventListener('click', handler), { initialProps: { handler: handler1 } } ); rerender({ handler: handler2 }); expect(removeSpy).toHaveBeenCalledWith('click', handler1); expect(addSpy).toHaveBeenCalledWith('click', handler2); addSpy.mockRestore(); removeSpy.mockRestore(); });
Exercise:

Create and test a useForm custom hook with the following features:

  1. Manages form state (values, errors, touched fields)
  2. Provides handleChange, handleBlur, and handleSubmit functions
  3. Validates fields based on a validation schema
  4. Prevents submission when form has errors
  5. Resets form after successful submission

Write comprehensive tests covering:

  • Initial state
  • Field updates
  • Validation on blur
  • Form submission (success and error cases)
  • Form reset

Best Practices for Hook Testing

  • Use renderHook for isolation: Test hooks independently when possible
  • Wrap state updates in act(): Ensures React processes updates correctly
  • Test with real providers: Use actual Context providers, not mocks
  • Mock external dependencies: Mock APIs, localStorage, timers, etc.
  • Test cleanup: Verify useEffect cleanup functions work correctly
  • Test edge cases: Test with null, undefined, empty values
  • Use fake timers judiciously: Only when testing time-dependent logic
Common Mistakes:
  • Forgetting to wrap state updates in act()
  • Not cleaning up mocks and spies
  • Testing hooks without providers when they require context
  • Not restoring real timers after using fake timers
  • Testing implementation details instead of behavior

Summary

Testing hooks and context is essential for maintaining robust React applications. Use renderHook for isolated hook testing, create custom wrappers for context testing, and always verify cleanup behavior. In the next lesson, we'll explore end-to-end testing with Cypress.