
Introduction
Testing React components is crucial for building reliable and maintainable applications. This chapter focuses on component-specific testing strategies, including unit testing individual components, integration testing component interactions, and testing component behavior with different props and states.
What Makes React Component Testing Unique?
React components have specific characteristics that require specialized testing approaches:
- JSX Rendering: Components return JSX that needs to be rendered and tested
- Props and State: Components have dynamic data that affects rendering
- Event Handling: User interactions need to be simulated and tested
- Lifecycle Methods: Component lifecycle behavior needs verification
- Context and Hooks: Modern React features require specific testing patterns
Testing Philosophy for React Components
The Testing Pyramid for React
- Unit Tests (70%): Test individual components in isolation
- Integration Tests (20%): Test component interactions
- End-to-End Tests (10%): Test complete user workflows
What to Test
Test:
- Component renders correctly with different props
- State changes work as expected
- Event handlers are called correctly
- Conditional rendering logic
- Component integration with context/hooks
Don’t Test:
- Implementation details (internal state variables)
- Third-party library functionality
- Styling details (unless critical for functionality)
Setting Up React Testing Library
React Testing Library is the recommended testing utility for React components, focusing on testing behavior rather than implementation.
Installation
npm install --save-dev @testing-library/react @testing-library/jest-dom
Basic Setup
// src/setupTests.js
import '@testing-library/jest-dom';
// src/test-utils.js
import React from 'react';
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
// Custom render function for components that need Router
const customRender = (ui, options) => {
const Wrapper = ({ children }) => (
<BrowserRouter>{children}</BrowserRouter>
);
return render(ui, { wrapper: Wrapper, ...options });
};
export * from '@testing-library/react';
export { customRender as render };
Testing Basic Components
Simple Component Test
// src/components/Greeting.js
import React from 'react';
const Greeting = ({ name, isLoggedIn }) => {
 if (!isLoggedIn) {
  return <div>Please log in</div>;
 }
Â
 return <h1>Hello, {name}!</h1>;
};
export default Greeting;
// src/components/Greeting.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
describe('Greeting Component', () => {
test('renders greeting message when logged in', () => {
render(<Greeting name="John" isLoggedIn={true} />);
expect(screen.getByText('Hello, John!')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});
test('renders login message when not logged in', () => {
render(<Greeting name="John" isLoggedIn={false} />);
expect(screen.getByText('Please log in')).toBeInTheDocument();
expect(screen.queryByText('Hello, John!')).not.toBeInTheDocument();
});
test('handles missing name gracefully', () => {
render(<Greeting isLoggedIn={true} />);
expect(screen.getByText('Hello, !')).toBeInTheDocument();
});
});
Testing Components with State
Counter Component Example
// src/components/Counter.js
import React, { useState } from 'react';
const Counter = ({ initialValue = 0, step = 1 }) => {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(count + step);
const decrement = () => setCount(count - step);
const reset = () => setCount(initialValue);
return (
<div>
<p data-testid="count-display">Count: {count}</p>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<button onClick={reset}>Reset</button>
</div>
);
};
export default Counter;
// src/components/Counter.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
describe('Counter Component', () => {
test('renders with initial value', () => {
render(<Counter initialValue={5} />);
expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 5');
});
test('increments count when increment button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByText('Increment');
await user.click(incrementButton);
expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 1');
});
test('decrements count when decrement button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialValue={5} />);
const decrementButton = screen.getByText('Decrement');
await user.click(decrementButton);
expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 4');
});
test('resets count when reset button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialValue={10} />);
// Change the count first
await user.click(screen.getByText('Increment'));
expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 11');
// Then reset
await user.click(screen.getByText('Reset'));
expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 10');
});
test('uses custom step value', async () => {
const user = userEvent.setup();
render(<Counter step={5} />);
await user.click(screen.getByText('Increment'));
expect(screen.getByTestId('count-display')).toHaveTextContent('Count: 5');
});
});
Testing Event Handlers and User Interactions
Form Component Testing
// src/components/LoginForm.js
import React, { useState } from 'react';
const LoginForm = ({ onSubmit, isLoading }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const validateForm = () => {
const newErrors = {};
if (!email) newErrors.email = 'Email is required';
if (!email.includes('@')) newErrors.email = 'Invalid email format';
if (!password) newErrors.password = 'Password is required';
if (password.length < 6) newErrors.password = 'Password must be at least 6 characters';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validateForm()) {
onSubmit({ email, password });
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
);
};
export default LoginForm;
// src/components/LoginForm.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm Component', () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
test('renders form fields', () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
expect(screen.getByLabelText('Email:')).toBeInTheDocument();
expect(screen.getByLabelText('Password:')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
});
test('updates input values when user types', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText('Email:');
const passwordInput = screen.getByLabelText('Password:');
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'password123');
expect(emailInput).toHaveValue('test@example.com');
expect(passwordInput).toHaveValue('password123');
});
test('shows validation errors for invalid input', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
const submitButton = screen.getByRole('button', { name: 'Login' });
await user.click(submitButton);
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
test('validates email format', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
const emailInput = screen.getByLabelText('Email:');
const submitButton = screen.getByRole('button', { name: 'Login' });
await user.type(emailInput, 'invalid-email');
await user.click(submitButton);
expect(screen.getByText('Invalid email format')).toBeInTheDocument();
});
test('submits form with valid data', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText('Email:'), 'test@example.com');
await user.type(screen.getByLabelText('Password:'), 'password123');
await user.click(screen.getByRole('button', { name: 'Login' }));
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
test('shows loading state', () => {
render(<LoginForm onSubmit={mockOnSubmit} isLoading={true} />);
const submitButton = screen.getByRole('button');
expect(submitButton).toHaveTextContent('Logging in...');
expect(submitButton).toBeDisabled();
});
});
Testing Components with Hooks
Custom Hook Testing
// src/hooks/useCounter.js
import { useState, useCallback } from 'react';
export const useCounter = (initialValue = 0) => {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
const decrement = useCallback(() => {
setCount(prev => prev - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
};
// src/hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
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();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Testing Components with Context
Context Provider Testing
// src/contexts/ThemeContext.js
import React, { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};
// src/components/ThemeButton.js
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
const ThemeButton = () => {
 const { theme, toggleTheme } = useTheme();
 return (
  <button
   onClick={toggleTheme}
   className={`theme-button theme-button--${theme}`}
  >
   Switch to {theme === 'light' ? 'dark' : 'light'} mode
  </button>
 );
};export default ThemeButton;
// src/components/ThemeButton.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../contexts/ThemeContext';
import ThemeButton from './ThemeButton';
const renderWithTheme = (component) => {
return render(
<ThemeProvider>
{component}
</ThemeProvider>
);
};
describe('ThemeButton Component', () => {
test('renders with initial theme', () => {
renderWithTheme(<ThemeButton />);
expect(screen.getByText('Switch to dark mode')).toBeInTheDocument();
});
test('toggles theme when clicked', async () => {
const user = userEvent.setup();
renderWithTheme(<ThemeButton />);
const button = screen.getByRole('button');
// Initial state
expect(button).toHaveTextContent('Switch to dark mode');
// After click
await user.click(button);
expect(button).toHaveTextContent('Switch to light mode');
// After second click
await user.click(button);
expect(button).toHaveTextContent('Switch to dark mode');
});
test('throws error when used outside provider', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
expect(() => {
render(<ThemeButton />);
}).toThrow('useTheme must be used within ThemeProvider');
consoleSpy.mockRestore();
});
});
Testing Asynchronous Components
Component with API Calls
// src/components/UserProfile.js
import React, { useState, useEffect } from 'react';
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const userData = await response.json();
setUser(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (userId) {
fetchUser();
}
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>No user found</div>;
return (
<div data-testid="user-profile">
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
</div>
);
};
export default UserProfile;
// src/components/UserProfile.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
// Mock fetch
global.fetch = jest.fn();
describe('UserProfile Component', () => {
beforeEach(() => {
fetch.mockClear();
});
test('shows loading state initially', () => {
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 1, name: 'John Doe', email: 'john@example.com', role: 'admin' })
});
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('displays user data after successful fetch', async () => {
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'admin'
};
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser
});
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByTestId('user-profile')).toBeInTheDocument();
});
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Email: john@example.com')).toBeInTheDocument();
expect(screen.getByText('Role: admin')).toBeInTheDocument();
});
test('displays error message on fetch failure', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('Error: Network error')).toBeInTheDocument();
});
});
test('displays error for non-ok response', async () => {
fetch.mockResolvedValueOnce({
ok: false,
status: 404
});
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('Error: Failed to fetch user')).toBeInTheDocument();
});
});
test('does not fetch when userId is not provided', () => {
render(<UserProfile />);
expect(fetch).not.toHaveBeenCalled();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
Snapshot Testing
Snapshot testing captures the rendered output of a component and compares it against a saved snapshot.
// src/components/Button.test.js
import React from 'react';
import { render } from '@testing-library/react';
import Button from './Button';
describe('Button Snapshots', () => {
 test('renders primary button snapshot', () => {
  const { container } = render(
   <Button variant="primary" size="large">
    Click me
   </Button>
  );
 Â
  expect(container.firstChild).toMatchSnapshot();
 });
 test('renders disabled button snapshot', () => {
  const { container } = render(
   <Button disabled>
    Disabled button
   </Button>
  );
 Â
  expect(container.firstChild).toMatchSnapshot();
 });
});
Testing Best Practices
1. Use Descriptive Test Names
// Good
test('displays error message when form submission fails')
// Bad
test('error handling')
2. Test User Behavior, Not Implementation
// Good - tests what user sees
expect(screen.getByText('Welcome, John!')).toBeInTheDocument();
// Bad - tests implementation details
expect(component.state.username).toBe('John');
3. Use Proper Queries
// Prefer accessible queries
screen.getByRole('button', { name: 'Submit' })
screen.getByLabelText('Email address')
// Over generic queries
screen.getByTestId('submit-button')
4. Group Related Tests
describe('LoginForm', () => {
describe('validation', () => {
test('shows error for empty email');
test('shows error for invalid email format');
});
describe('submission', () => {
test('calls onSubmit with form data');
test('prevents submission with validation errors');
});
});
5. Use Setup and Teardown
describe('Component Tests', () => {
 let mockFn;
 beforeEach(() => {
  mockFn = jest.fn();
 });
 afterEach(() => {
  jest.clearAllMocks();
 });
});
Common Testing Patterns
Testing Lists and Dynamic Content
test('renders list of items', async () => {
const items = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' }
];
render(<ItemList items={items} />);
// Wait for async rendering
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
items.forEach(item => {
expect(screen.getByText(item.name)).toBeInTheDocument();
});
});
Testing Conditional Rendering
test('shows different content based on user role', () => {
 const { rerender } = render(<Dashboard user={{ role: 'user' }} />);
 expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
 rerender(<Dashboard user={{ role: 'admin' }} />);
 expect(screen.getByText('Admin Panel')).toBeInTheDocument();
});
Testing Error Boundaries
const ThrowError = ({ shouldThrow }) => {
 if (shouldThrow) {
  throw new Error('Test error');
 }
 return <div>No error</div>;
};
test('error boundary catches and displays error', () => {
 const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
 render(
  <ErrorBoundary>
   <ThrowError shouldThrow={true} />
  </ErrorBoundary>
 );
 expect(screen.getByText('Something went wrong')).toBeInTheDocument();
 consoleSpy.mockRestore();
});
Performance Testing
Testing Component Re-renders
test('component does not re-render unnecessarily', () => {
 const ExpensiveComponent = React.memo(({ data }) => {
  const renderCount = React.useRef(0);
  renderCount.current += 1;
 Â
  return <div data-testid="render-count">{renderCount.current}</div>;
 });
 const { rerender } = render(<ExpensiveComponent data="test" />);
 expect(screen.getByTestId('render-count')).toHaveTextContent('1');
 // Same props should not cause re-render
 rerender(<ExpensiveComponent data="test" />);
 expect(screen.getByTestId('render-count')).toHaveTextContent('1');
 // Different props should cause re-render
 rerender(<ExpensiveComponent data="new-test" />);
 expect(screen.getByTestId('render-count')).toHaveTextContent('2');
});
Debugging Tests
Using debug()
test('debug failing test', () => {
render(<MyComponent />);
// Print current DOM state
screen.debug();
// Print specific element
screen.debug(screen.getByRole('button'));
});
Using logRoles()
import { logRoles } from '@testing-library/dom';
test('find available roles', () => {
 const { container } = render(<MyComponent />);
 logRoles(container);
});
Conclusion
Testing React components effectively requires understanding both React’s component model and testing best practices. Focus on testing user behavior rather than implementation details, use appropriate queries and assertions, and structure your tests for maintainability.
Key takeaways:
- Test what users see and do, not internal implementation
- Use React Testing Library’s accessible queries
- Test both happy paths and error scenarios
- Mock external dependencies appropriately
- Group related tests logically
- Write descriptive test names and use proper setup/teardown
In the next chapter, we’ll explore End-to-End Testing with Selenium to test complete user workflows across your React application.