Testing Strategies & Patterns
Comprehensive testing guide for senior React developers. Learn production-grade testing patterns with React Testing Library, Jest, Vitest, and Playwright. Master testing hooks, context, async operations, and accessibility.
๐งช 1. React Testing Library Best Practices
Stop testing implementation details โ test what your USERS see and do! ๐ The junior dev tests state variables and CSS classes. The senior dev tests button clicks and screen content. Level up your testing game by thinking like a user, not like a compiler! ๐ฎ
๐ด Impact: CRITICAL โ Testing the right way means tests that survive refactors โ fragile tests are worse than no tests at all
๐ In this section: Semantic Queries โข User Event Simulation โข Accessible Testing Patterns โข Anti-Patterns to Avoid
Testing Philosophy: React Testing Library encourages testing from the user's perspective. Query by role, label, text, and other accessible attributes. Avoid testing implementation details like state variables or internal methods.
// โ WRONG: Testing implementation detailsimport { render, screen } from '@testing-library/react';import { useState } from 'react';const Counter = () => {const [count, setCount] = useState(0);return (<div><button data-testid="increment" onClick={() => setCount(count + 1)}>Increment</button><span data-testid="count">{count}</span></div>);};// Bad: Testing internal statetest('counter state updates', () => {const { container } = render(<Counter />);const button = container.querySelector('[data-testid="increment"]');button?.click();// Testing implementation, not user behaviorexpect(container.querySelector('[data-testid="count"]')?.textContent).toBe('1');});
// โ CORRECT: Testing user behaviorimport { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { useState } from 'react';const Counter = () => {const [count, setCount] = useState(0);return (<div><button onClick={() => setCount(count + 1)}>Increment</button><span aria-label="count">{count}</span></div>);};// Good: Testing what user sees and doestest('increments counter when button is clicked', async () => {const user = userEvent.setup();render(<Counter />);// Query by accessible role/labelconst button = screen.getByRole('button', { name: /increment/i });const countDisplay = screen.getByLabelText('count');expect(countDisplay).toHaveTextContent('0');// Simulate user interactionawait user.click(button);expect(countDisplay).toHaveTextContent('1');});
๐ช 2. Testing Custom Hooks
Hooks are tricky little creatures โ they can't live outside components! ๐ But with renderHook, you can test them in isolation like a proper scientist in a lab. Master fake timers, rerender triggers, and async hook testing to bulletproof your custom logic! ๐ช
๐ Impact: HIGH โ Custom hooks are the backbone of reusable logic โ testing them properly prevents bugs from spreading everywhere
๐ In this section: renderHook API โข Fake Timers โข Rerender Testing โข Debounce Hook Example
// Custom Hook: useDebounceimport { useState, useEffect } from 'react';export function useDebounce<T>(value: T, delay: number): T {const [debouncedValue, setDebouncedValue] = useState<T>(value);useEffect(() => {const handler = setTimeout(() => {setDebouncedValue(value);}, delay);return () => {clearTimeout(handler);};}, [value, delay]);return debouncedValue;}// โ Testing with renderHookimport { renderHook, waitFor } from '@testing-library/react';import { useDebounce } from './useDebounce';describe('useDebounce', () => {beforeEach(() => {jest.useFakeTimers();});afterEach(() => {jest.runOnlyPendingTimers();jest.useRealTimers();});it('should debounce value changes', () => {const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay),{ initialProps: { value: 'initial', delay: 500 } });expect(result.current).toBe('initial');// Update valuererender({ value: 'updated', delay: 500 });// Should still be initial (not debounced yet)expect(result.current).toBe('initial');// Fast-forward timejest.advanceTimersByTime(500);// Now should be updatedexpect(result.current).toBe('updated');});it('should cancel previous timeout on rapid changes', () => {const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay),{ initialProps: { value: 'first', delay: 500 } });rerender({ value: 'second', delay: 500 });rerender({ value: 'third', delay: 500 });jest.advanceTimersByTime(500);// Should only have the last valueexpect(result.current).toBe('third');});});
๐ญ 3. Testing Context & Providers
Context is everywhere in React apps, but testing it can feel like untangling Christmas lights! ๐ Learn the secret sauce: custom render functions that wrap your components with the right providers. Once you nail this pattern, testing contextified components becomes a breeze! ๐ฌ๏ธ
๐ต Impact: MEDIUM โ A reusable test render wrapper saves you from writing provider boilerplate in every single test file
๐ In this section: Custom Render Functions โข Provider Wrappers โข Mock Context Values โข Theme Toggle Testing
// Theme Contextimport { createContext, useContext, useState, ReactNode } from 'react';type Theme = 'light' | 'dark';interface ThemeContextType {theme: Theme;toggleTheme: () => void;}const ThemeContext = createContext<ThemeContextType | undefined>(undefined);export function ThemeProvider({ children }: { children: ReactNode }) {const [theme, setTheme] = useState<Theme>('light');const toggleTheme = () => {setTheme(prev => prev === 'light' ? 'dark' : 'light');};return (<ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>);}export function useTheme() {const context = useContext(ThemeContext);if (!context) {throw new Error('useTheme must be used within ThemeProvider');}return context;}// Component using contextfunction ThemeToggle() {const { theme, toggleTheme } = useTheme();return (<button onClick={toggleTheme} aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}>Current: {theme}</button>);}// โ Testing with custom renderimport { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { ThemeProvider, ThemeToggle } from './Theme';// Custom render functionfunction renderWithTheme(ui: React.ReactElement) {return render(<ThemeProvider>{ui}</ThemeProvider>);}test('toggles theme', async () => {const user = userEvent.setup();renderWithTheme(<ThemeToggle />);const button = screen.getByRole('button');expect(button).toHaveTextContent('Current: light');await user.click(button);expect(button).toHaveTextContent('Current: dark');});// โ Testing with custom provider valuesfunction renderWithCustomTheme(ui: React.ReactElement, theme: Theme = 'light') {const Wrapper = ({ children }: { children: React.ReactElement }) => (<ThemeContext.Provider value={{ theme, toggleTheme: jest.fn() }}>{children}</ThemeContext.Provider>);return render(ui, { wrapper: Wrapper });}test('renders with custom theme', () => {renderWithCustomTheme(<ThemeToggle />, 'dark');expect(screen.getByRole('button')).toHaveTextContent('Current: dark');});
๐ช 4. Mocking Strategies
Mocking is an art form! ๐จ From intercepting network requests with MSW to jest.fn() wizardry, this section covers every trick in the mocking playbook. Stop hitting real APIs in tests and start controlling your test environment like a puppeteer! ๐งต
๐ Impact: HIGH โ Proper mocking makes tests fast, reliable, and deterministic โ no more flaky tests ruining your CI pipeline
๐ In this section: MSW (Mock Service Worker) โข Module Mocks โข Function Mocks โข Window API Mocking โข Partial Mocks
// โ Mocking API calls with MSW (Mock Service Worker)import { rest } from 'msw';import { setupServer } from 'msw/node';import { render, screen, waitFor } from '@testing-library/react';import { UserProfile } from './UserProfile';// Setup MSW serverconst server = setupServer(rest.get('/api/user/:id', (req, res, ctx) => {return res(ctx.json({id: req.params.id,name: 'John Doe',email: 'john@example.com',}));}));beforeAll(() => server.listen());afterEach(() => server.resetHandlers());afterAll(() => server.close());test('displays user profile', async () => {render(<UserProfile userId="123" />);await waitFor(() => {expect(screen.getByText('John Doe')).toBeInTheDocument();});});// โ Mocking modules// utils/api.tsexport const fetchUser = async (id: string) => {const response = await fetch(`/api/user/${id}`);return response.json();};// Componentimport { fetchUser } from './utils/api';// Test filejest.mock('./utils/api');import { fetchUser } from './utils/api';const mockFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>;test('handles API error', async () => {mockFetchUser.mockRejectedValueOnce(new Error('Network error'));render(<UserProfile userId="123" />);await waitFor(() => {expect(screen.getByText(/error/i)).toBeInTheDocument();});});// โ Mocking window methodstest('handles geolocation', () => {const mockGeolocation = {getCurrentPosition: jest.fn((success) => {success({coords: {latitude: 40.7128,longitude: -74.0060,},});}),};global.navigator.geolocation = mockGeolocation as any;render(<LocationComponent />);// Test location-based behavior});// โ Partial mocksjest.mock('./api', () => ({...jest.requireActual('./api'),fetchUser: jest.fn(),}));
๐ 5. Integration Testing Patterns
Unit tests check the bricks, but integration tests check the building! ๐๏ธ Test real user flows โ filling forms, clicking buttons, waiting for data. When components work together in tests, they'll work together in production. It's like a dress rehearsal before opening night! ๐ฌ
๐ด Impact: CRITICAL โ Integration tests catch the bugs that unit tests miss โ they test the REAL user experience end to end
๐ In this section: Complete User Flows โข Provider Wrappers โข Search & Filter Testing โข Login Flow Testing
// โ Integration test: Complete user flowimport { render, screen, waitFor } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { QueryClient, QueryClientProvider } from '@tanstack/react-query';import { LoginForm } from './LoginForm';import { Dashboard } from './Dashboard';function createTestQueryClient() {return new QueryClient({defaultOptions: {queries: { retry: false },mutations: { retry: false },},});}function renderWithProviders(ui: React.ReactElement) {const queryClient = createTestQueryClient();return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);}test('complete login flow', async () => {const user = userEvent.setup();renderWithProviders(<LoginForm />);// Fill formconst emailInput = screen.getByLabelText(/email/i);const passwordInput = screen.getByLabelText(/password/i);const submitButton = screen.getByRole('button', { name: /login/i });await user.type(emailInput, 'user@example.com');await user.type(passwordInput, 'password123');await user.click(submitButton);// Wait for navigation/state changeawait waitFor(() => {expect(screen.getByText(/welcome/i)).toBeInTheDocument();});// Verify dashboard rendersexpect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument();});// โ Testing component interactionstest('search and filter integration', async () => {const user = userEvent.setup();renderWithProviders(<ProductList />);// Searchconst searchInput = screen.getByPlaceholderText(/search/i);await user.type(searchInput, 'laptop');// Apply filterconst filterButton = screen.getByRole('button', { name: /filter/i });await user.click(filterButton);const categoryCheckbox = screen.getByLabelText('Electronics');await user.click(categoryCheckbox);// Verify resultsawait waitFor(() => {const results = screen.getAllByTestId('product-item');expect(results).toHaveLength(5);results.forEach(item => {expect(item).toHaveTextContent(/laptop/i);});});});
๐ญ 6. E2E Testing with Playwright
Playwright is the final boss of testing! ๐ฎ It launches real browsers, clicks real buttons, and tests your ENTIRE app from start to finish. Login flows, checkout processes, visual regressions โ if a user can break it, Playwright can catch it. Cross-browser testing? It's got Chrome, Firefox, AND Safari covered! ๐
๐ด Impact: CRITICAL โ E2E tests are your last line of defense โ they catch the bugs that slip through unit and integration tests
๐ In this section: Playwright Setup โข Auth Flows โข Checkout Testing โข Visual Regression โข Cross-Browser
// โ Playwright E2E Testimport { test, expect } from '@playwright/test';test.describe('User Authentication Flow', () => {test('user can login and access dashboard', async ({ page }) => {// Navigate to login pageawait page.goto('http://localhost:3000/login');// Fill login formawait page.fill('[name="email"]', 'user@example.com');await page.fill('[name="password"]', 'password123');await page.click('button[type="submit"]');// Wait for navigationawait page.waitForURL('**/dashboard');// Verify dashboard contentawait expect(page.locator('h1')).toContainText('Dashboard');await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();});test('user can complete checkout flow', async ({ page }) => {await page.goto('http://localhost:3000/products');// Add product to cartawait page.click('[data-testid="product-1"] button');await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');// Go to cartawait page.click('[data-testid="cart-icon"]');await page.waitForURL('**/cart');// Proceed to checkoutawait page.click('button:has-text("Checkout")');await page.waitForURL('**/checkout');// Fill shipping infoawait page.fill('[name="address"]', '123 Main St');await page.fill('[name="city"]', 'New York');await page.selectOption('[name="country"]', 'US');// Complete orderawait page.click('button:has-text("Place Order")');// Verify successawait expect(page.locator('[data-testid="order-success"]')).toBeVisible();});});// โ Visual regression testingtest('dashboard visual regression', async ({ page }) => {await page.goto('http://localhost:3000/dashboard');await page.waitForLoadState('networkidle');await expect(page).toHaveScreenshot('dashboard.png');});// โ Testing across browserstest.describe('Cross-browser testing', () => {['chromium', 'firefox', 'webkit'].forEach(browserName => {test(`works on ${browserName}`, async ({ browser }) => {const context = await browser.newContext();const page = await context.newPage();await page.goto('http://localhost:3000');await expect(page.locator('h1')).toBeVisible();await context.close();});});});
โฟ 7. Accessibility Testing
Building for everyone isn't optional โ it's a superpower! ๐ช Accessibility testing ensures your app works for keyboard users, screen reader users, and everyone in between. jest-axe catches violations automatically, and you'll learn to test focus traps, ARIA attributes, and keyboard navigation like a pro! ๐
๐ Impact: HIGH โ Accessible apps serve ALL users โ and automated a11y tests catch issues before they reach production
๐ In this section: jest-axe Setup โข Keyboard Navigation โข ARIA Attributes โข Focus Management Testing
// โ Accessibility testing with jest-axeimport { render } from '@testing-library/react';import { axe, toHaveNoViolations } from 'jest-axe';import { Button } from './Button';expect.extend(toHaveNoViolations);test('button has no accessibility violations', async () => {const { container } = render(<Button>Click me</Button>);const results = await axe(container);expect(results).toHaveNoViolations();});// โ Testing keyboard navigationimport { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';test('modal can be closed with Escape key', async () => {const user = userEvent.setup();render(<Modal isOpen={true} onClose={jest.fn()} />);const modal = screen.getByRole('dialog');expect(modal).toBeVisible();await user.keyboard('{Escape}');expect(modal).not.toBeVisible();});// โ Testing ARIA attributestest('accordion has correct ARIA attributes', () => {render(<Accordion />);const button = screen.getByRole('button', { expanded: false });expect(button).toHaveAttribute('aria-controls');expect(button).toHaveAttribute('aria-expanded', 'false');// After clickinguserEvent.click(button);expect(button).toHaveAttribute('aria-expanded', 'true');});// โ Testing focus managementtest('focus is trapped in modal', async () => {const user = userEvent.setup();render(<Modal isOpen={true} />);const firstFocusable = screen.getByRole('button', { name: /close/i });const lastFocusable = screen.getByRole('button', { name: /submit/i });// Tab should cycle through focusable elementsawait user.tab();expect(firstFocusable).toHaveFocus();await user.tab();// Should not escape modalexpect(document.body).not.toHaveFocus();});
๐ด 8. Test-Driven Development (TDD)
Red. Green. Refactor. Repeat! ๐ TDD flips development on its head โ write the test FIRST, watch it fail (red), make it pass (green), then clean it up (refactor). It feels weird at first, but once you get the flow, you'll write better code faster and with way more confidence! ๐ฏ
๐ต Impact: MEDIUM โ TDD produces better-designed code with built-in test coverage โ it's a discipline that pays dividends over time
๐ In this section: Red-Green-Refactor Cycle โข useLocalStorage TDD Example โข Step-by-Step Implementation
// โ TDD Example: Building a useLocalStorage hook// Step 1: RED - Write failing testdescribe('useLocalStorage', () => {it('should return initial value when key does not exist', () => {const { result } = renderHook(() => useLocalStorage('test-key', 'default'));expect(result.current[0]).toBe('default');});it('should save and retrieve value', () => {const { result } = renderHook(() => useLocalStorage('test-key', 'default'));act(() => {result.current[1]('new-value');});expect(result.current[0]).toBe('new-value');expect(localStorage.getItem('test-key')).toBe('"new-value"');});});// Step 2: GREEN - Write minimal implementationfunction useLocalStorage<T>(key: string, initialValue: T) {const [storedValue, setStoredValue] = useState<T>(() => {try {const item = window.localStorage.getItem(key);return item ? JSON.parse(item) : initialValue;} catch {return initialValue;}});const setValue = (value: T) => {try {setStoredValue(value);window.localStorage.setItem(key, JSON.stringify(value));} catch (error) {console.error(error);}};return [storedValue, setValue] as const;}// Step 3: REFACTOR - Improve implementationfunction useLocalStorage<T>(key: string, initialValue: T) {const [storedValue, setStoredValue] = useState<T>(() => {if (typeof window === 'undefined') return initialValue;try {const item = window.localStorage.getItem(key);return item ? JSON.parse(item) : initialValue;} catch {return initialValue;}});const setValue = useCallback((value: T | ((val: T) => T)) => {try {const valueToStore = value instanceof Function ? value(storedValue) : value;setStoredValue(valueToStore);if (typeof window !== 'undefined') {window.localStorage.setItem(key, JSON.stringify(valueToStore));}} catch (error) {console.error(`Error saving to localStorage: ${error}`);}}, [key, storedValue]);return [storedValue, setValue] as const;}