Component Testing Strategies
Test React components effectively with Testing Library best practices
Component Testing Strategies
Component tests verify that your UI components work correctly in isolation. Testing Library helps you test components the way users interact with them, not how they're implemented.
Testing Library Philosophy
The more your tests resemble the way your software is used, the more confidence they can give you. - Kent C. Dodds
// ❌ BAD: Testing implementation
test('uses useState', () => {
const wrapper = shallow(<Counter />);
expect(wrapper.state('count')).toBe(0);
});
// ✅ GOOD: Testing behavior
test('increments counter', () => {
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});Setup
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event// jest.setup.ts
import '@testing-library/jest-dom';
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});Query Priority
Use queries in this order:
- Accessible to everyone:
getByRole,getByLabelText,getByPlaceholderText,getByText - Semantic queries:
getByAltText,getByTitle - Test IDs:
getByTestId(last resort)
// 1. ✅ BEST: By role (accessible)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
// 2. ✅ GOOD: By label (accessible)
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter your email');
// 3. ✅ OK: By text
screen.getByText('Welcome back');
// 4. ⚠️ LAST RESORT: By test ID
screen.getByTestId('submit-button');Testing Patterns
1. User Interactions
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('user can submit form', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
// Type in inputs
await user.type(
screen.getByRole('textbox', { name: /email/i }),
'test@example.com'
);
await user.type(
screen.getByLabelText(/password/i),
'password123'
);
// Submit form
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Verify
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});2. Async Operations
test('loads and displays data', async () => {
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data to load
const name = await screen.findByText('John Doe');
expect(name).toBeInTheDocument();
// Loading indicator is gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
// With waitFor
test('displays error message', async () => {
render(<DataComponent />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});3. Testing Hooks
import { renderHook, act } from '@testing-library/react';
test('useCounter increments count', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
// With props
test('useCounter starts with initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
// With context
test('useAuth returns user', () => {
const wrapper = ({ children }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toBeDefined();
});4. Component States
test('button shows all states', async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<Button onClick={onClick}>Click me</Button>);
const button = screen.getByRole('button');
// Default state
expect(button).toBeEnabled();
expect(button).toHaveTextContent('Click me');
// Hover state (CSS, check with toHaveStyle if needed)
await user.hover(button);
// Click
await user.click(button);
expect(onClick).toHaveBeenCalled();
// Disabled state
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
// Loading state
render(<Button isLoading>Click me</Button>);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});5. Error Boundaries
test('error boundary catches errors', () => {
const ThrowError = () => {
throw new Error('Test error');
};
// Suppress console.error for this test
const spy = jest.spyOn(console, 'error').mockImplementation();
render(
<ErrorBoundary fallback={<div>Error occurred</div>}>
<ThrowError />
</ErrorBoundary>
);
expect(screen.getByText('Error occurred')).toBeInTheDocument();
spy.mockRestore();
});Custom Render Function
// test-utils.tsx
import { render as rtlRender } from '@testing-library/react';
import { ThemeProvider } from '@/components/ThemeProvider';
import { AuthProvider } from '@/contexts/AuthContext';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function render(
ui: React.ReactElement,
{
initialAuth = null,
theme = 'light',
...options
} = {}
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme={theme}>
<AuthProvider initialUser={initialAuth}>
{children}
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
return rtlRender(ui, { wrapper: Wrapper, ...options });
}
export * from '@testing-library/react';
export { render };
// Usage
import { render, screen } from '@/test-utils';
test('displays user name', () => {
render(<Dashboard />, {
initialAuth: { name: 'John' },
theme: 'dark',
});
expect(screen.getByText('John')).toBeInTheDocument();
});Mocking
API Calls (MSW)
// mocks/handlers.ts
import { rest } from 'msw';
export const handlers = [
rest.get('/api/user', (req, res, ctx) => {
return res(
ctx.json({
id: '1',
name: 'John Doe',
email: 'john@example.com',
})
);
}),
rest.post('/api/login', (req, res, ctx) => {
return res(
ctx.json({ success: true, token: 'abc123' })
);
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// jest.setup.ts
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Context/Providers
test('uses theme context', () => {
const MockThemeProvider = ({ children }) => (
<ThemeContext.Provider value={{ theme: 'dark', toggleTheme: jest.fn() }}>
{children}
</ThemeContext.Provider>
);
render(
<MockThemeProvider>
<ThemedComponent />
</MockThemeProvider>
);
expect(screen.getByTestId('theme-indicator')).toHaveTextContent('dark');
});Router
// Mock Next.js router
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
pathname: '/test',
query: {},
}),
usePathname: () => '/test',
useSearchParams: () => new URLSearchParams(),
}));
test('navigates on click', () => {
const push = jest.fn();
jest.spyOn(require('next/navigation'), 'useRouter').mockReturnValue({
push,
});
render(<NavigationButton />);
fireEvent.click(screen.getByRole('button'));
expect(push).toHaveBeenCalledWith('/new-page');
});Accessibility Testing
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('has no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Snapshot Testing
// Use sparingly!
test('matches snapshot', () => {
const { container } = render(<Button>Click me</Button>);
expect(container.firstChild).toMatchSnapshot();
});
// Inline snapshots (better)
test('renders correctly', () => {
const { container } = render(<Button>Click me</Button>);
expect(container.firstChild).toMatchInlineSnapshot(`
<button class="btn btn-primary">
Click me
</button>
`);
});Best Practices
- Test behavior, not implementation
- Use accessible queries (role, label)
- Avoid test IDs unless necessary
- Don't test implementation details
- Test user interactions with userEvent
- Mock external dependencies (API, router)
- Use custom render for providers
- Keep tests simple and readable
Common Mistakes
❌ Using getByTestId everywhere
✅ Use getByRole, getByLabelText
❌ Testing implementation details
✅ Test user-facing behavior
❌ Checking state/props directly
✅ Test what user sees
❌ Shallow rendering
✅ Full rendering with Testing Library
❌ Not waiting for async
✅ Use findBy, waitFor
Component testing done right gives you confidence to refactor without breaking functionality.