Testing React Components: A Practical Guide
Testing doesn't have to be painful. Here's a practical approach that actually helps.
The Testing Stack
- **Vitest** - Test runner (Jest-compatible, faster)
- **React Testing Library** - Testing utilities
- **MSW** - API mocking
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
Philosophy
Test behavior, not implementation.
// ❌ Testing implementation
expect(wrapper.state('isOpen')).toBe(true);
// ✅ Testing behavior
expect(screen.getByRole('dialog')).toBeInTheDocument();
Users don't care about your state. They care about what they see.
Basic Component Test
// Button.jsx
function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
}
// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
test('renders button text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
Testing Async Components
// UserProfile.jsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import UserProfile from './UserProfile';
const server = setupServer(
http.get('/api/users/:id', () => {
return HttpResponse.json({ name: 'Chris' });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('loads and displays user', async () => {
render(<UserProfile userId="1" />);
// Initially shows loading
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Eventually shows user
await waitFor(() => {
expect(screen.getByText('Chris')).toBeInTheDocument();
});
});
Testing Form Submission
test('submits form with user input', async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Fill form
await userEvent.type(screen.getByLabelText('Email'), 'test@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
// Submit
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
// Check submission
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
What to Test
**Do test:**
- User interactions
- Conditional rendering
- Form validation feedback
- Error states
- Loading states
**Don't test:**
- Implementation details
- Third-party libraries
- Static content
- Every edge case (diminishing returns)
My Rule
If it can break in a way that affects users, test it. Otherwise, maybe don't.