RE
Testing
React testing v1.0.0
React Testing
Overview
React testing encompasses unit tests for components and hooks, integration tests for user flows, and end-to-end tests for complete features. This skill covers React Testing Library, MSW for mocking, and testing best practices.
Key Concepts
Testing Pyramid for React
┌─────────────────────────────────────────────────────────────┐
│ React Testing Strategy │
├─────────────────────────────────────────────────────────────┤
│ │
│ /\ │
│ / \ │
│ / E2E\ Cypress / Playwright │
│ / \ • Full user journeys │
│ /────────\ • Critical paths │
│ /Integration\ React Testing Library │
│ / \ • Component interactions │
│ /──────────────\ • Hook behavior │
│ / Unit \ Jest + RTL │
│ / \ • Isolated components │
│ /────────────────────\• Pure functions │
│ │
│ Testing Library Philosophy: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ "The more your tests resemble the way your software │ │
│ │ is used, the more confidence they can give you." │ │
│ │ │ │
│ │ Query Priority: │ │
│ │ 1. getByRole (accessible elements) │ │
│ │ 2. getByLabelText (form fields) │ │
│ │ 3. getByPlaceholderText │ │
│ │ 4. getByText (non-interactive elements) │ │
│ │ 5. getByTestId (last resort) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Best Practices
1. Test Behavior, Not Implementation
Focus on what users see and do, not internal state.
2. Use Accessible Queries
Prefer getByRole to encourage accessible markup.
3. Avoid Testing Implementation Details
Don’t test internal state or lifecycle methods.
4. Mock at Network Boundary
Use MSW to mock API calls, not component internals.
5. Write Maintainable Tests
Avoid testing library internals; focus on public APIs.
Code Examples
Example 1: Component Testing with RTL
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
// Setup user event instance for each test
const user = userEvent.setup();
it('renders login form with email and password fields', () => {
render(<LoginForm onSubmit={jest.fn()} />);
// Use accessible queries
expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
it('shows validation errors for invalid input', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
// Submit empty form
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Check for error messages
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
it('shows error for invalid email format', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
await user.type(screen.getByRole('textbox', { name: /email/i }), 'invalid-email');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/invalid email format/i)).toBeInTheDocument();
});
it('calls onSubmit with form data when valid', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(
screen.getByRole('textbox', { name: /email/i }),
'user@example.com'
);
await user.type(screen.getByLabelText(/password/i), 'securePassword123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'securePassword123',
});
});
it('disables submit button while loading', async () => {
render(<LoginForm onSubmit={jest.fn()} isLoading />);
expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
});
it('displays error message from props', () => {
render(
<LoginForm
onSubmit={jest.fn()}
error="Invalid credentials"
/>
);
expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
});
});
Example 2: Testing Hooks
import { renderHook, act, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useCounter, useAsync, useLocalStorage } from './hooks';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('respects min and max bounds', () => {
const { result } = renderHook(() =>
useCounter(5, { min: 0, max: 10 })
);
// Try to exceed max
act(() => {
for (let i = 0; i < 20; i++) result.current.increment();
});
expect(result.current.count).toBe(10);
// Try to go below min
act(() => {
for (let i = 0; i < 20; i++) result.current.decrement();
});
expect(result.current.count).toBe(0);
});
});
describe('useAsync', () => {
it('handles successful async operation', async () => {
const mockFn = jest.fn().mockResolvedValue({ data: 'test' });
const { result } = renderHook(() => useAsync(mockFn, true));
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
// Wait for completion
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ data: 'test' });
expect(result.current.error).toBeNull();
});
it('handles error', async () => {
const error = new Error('Failed');
const mockFn = jest.fn().mockRejectedValue(error);
const { result } = renderHook(() => useAsync(mockFn, true));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error).toBe(error);
});
it('can execute manually', async () => {
const mockFn = jest.fn().mockResolvedValue('result');
const { result } = renderHook(() => useAsync(mockFn, false));
expect(mockFn).not.toHaveBeenCalled();
await act(async () => {
await result.current.execute();
});
expect(mockFn).toHaveBeenCalledTimes(1);
expect(result.current.data).toBe('result');
});
});
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns initial value when storage is empty', () => {
const { result } = renderHook(() =>
useLocalStorage('key', 'default')
);
expect(result.current[0]).toBe('default');
});
it('returns stored value when present', () => {
localStorage.setItem('key', JSON.stringify('stored'));
const { result } = renderHook(() =>
useLocalStorage('key', 'default')
);
expect(result.current[0]).toBe('stored');
});
it('updates localStorage when value changes', () => {
const { result } = renderHook(() =>
useLocalStorage('key', 'initial')
);
act(() => {
result.current[1]('updated');
});
expect(result.current[0]).toBe('updated');
expect(localStorage.getItem('key')).toBe(JSON.stringify('updated'));
});
});
Example 3: API Mocking with MSW
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Setup MSW server
const server = setupServer(
// Default handlers
rest.get('/api/users/:id', (req, res, ctx) => {
const { id } = req.params;
return res(
ctx.json({
id,
name: 'John Doe',
email: 'john@example.com',
})
);
}),
rest.patch('/api/users/:id', async (req, res, ctx) => {
const { id } = req.params;
const body = await req.json();
return res(
ctx.json({
id,
...body,
})
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Test wrapper with providers
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
}
describe('UserProfile', () => {
const user = userEvent.setup();
it('displays user data after loading', async () => {
render(<UserProfile userId="1" />, { wrapper: createWrapper() });
// Should show loading state initially
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Should display user data
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
it('handles API error', async () => {
// Override handler for this test
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server error' }));
})
);
render(<UserProfile userId="1" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText(/error loading user/i)).toBeInTheDocument();
});
});
it('updates user successfully', async () => {
render(<UserProfile userId="1" />, { wrapper: createWrapper() });
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// Click edit button
await user.click(screen.getByRole('button', { name: /edit/i }));
// Update name
const nameInput = screen.getByLabelText(/name/i);
await user.clear(nameInput);
await user.type(nameInput, 'Jane Doe');
// Save
await user.click(screen.getByRole('button', { name: /save/i }));
// Verify update
await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});
});
it('handles network failure gracefully', async () => {
server.use(
rest.patch('/api/users/:id', (req, res) => {
return res.networkError('Failed to connect');
})
);
render(<UserProfile userId="1" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /edit/i }));
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(screen.getByText(/failed to update/i)).toBeInTheDocument();
});
});
});
Example 4: Testing Context and Providers
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from './ThemeContext';
import { AuthProvider, useAuth } from './AuthContext';
// Test component that uses context
function ThemeConsumer() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<button onClick={toggleTheme}>Toggle</button>
</div>
);
}
describe('ThemeContext', () => {
const user = userEvent.setup();
it('provides default theme', () => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
expect(screen.getByTestId('theme')).toHaveTextContent('light');
});
it('toggles theme', async () => {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>
);
await user.click(screen.getByRole('button', { name: /toggle/i }));
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});
it('throws error when used outside provider', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<ThemeConsumer />)).toThrow(
'useTheme must be used within ThemeProvider'
);
consoleSpy.mockRestore();
});
});
// Custom render with all providers
interface RenderOptions {
initialUser?: User | null;
initialTheme?: 'light' | 'dark';
}
function renderWithProviders(
ui: React.ReactElement,
options: RenderOptions = {}
) {
const { initialUser = null, initialTheme = 'light' } = options;
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<AuthProvider initialUser={initialUser}>
<ThemeProvider initialTheme={initialTheme}>
{children}
</ThemeProvider>
</AuthProvider>
);
}
return render(ui, { wrapper: Wrapper });
}
describe('Protected Component', () => {
it('shows login prompt when not authenticated', () => {
renderWithProviders(<ProtectedPage />, { initialUser: null });
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
});
it('shows content when authenticated', () => {
const user = { id: '1', name: 'John' };
renderWithProviders(<ProtectedPage />, { initialUser: user });
expect(screen.getByText(/welcome, john/i)).toBeInTheDocument();
});
});
Example 5: Integration and E2E Tests
// Integration test for complete user flow
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
describe('Shopping Cart Flow', () => {
const user = userEvent.setup();
function renderApp() {
return render(
<BrowserRouter>
<App />
</BrowserRouter>
);
}
it('completes purchase flow', async () => {
renderApp();
// Navigate to products
await user.click(screen.getByRole('link', { name: /products/i }));
// Add item to cart
const productCard = screen.getByTestId('product-1');
await user.click(within(productCard).getByRole('button', { name: /add to cart/i }));
// Verify cart badge updates
expect(screen.getByTestId('cart-badge')).toHaveTextContent('1');
// Go to cart
await user.click(screen.getByRole('link', { name: /cart/i }));
// Verify item in cart
expect(screen.getByText('Product 1')).toBeInTheDocument();
// Proceed to checkout
await user.click(screen.getByRole('button', { name: /checkout/i }));
// Fill shipping info
await user.type(screen.getByLabelText(/address/i), '123 Main St');
await user.type(screen.getByLabelText(/city/i), 'Anytown');
await user.type(screen.getByLabelText(/zip/i), '12345');
// Submit order
await user.click(screen.getByRole('button', { name: /place order/i }));
// Verify confirmation
await waitFor(() => {
expect(screen.getByText(/order confirmed/i)).toBeInTheDocument();
});
});
});
// Cypress E2E test
describe('Login Flow', () => {
beforeEach(() => {
cy.visit('/login');
});
it('logs in successfully with valid credentials', () => {
cy.findByRole('textbox', { name: /email/i }).type('user@example.com');
cy.findByLabelText(/password/i).type('password123');
cy.findByRole('button', { name: /sign in/i }).click();
cy.url().should('include', '/dashboard');
cy.findByText(/welcome back/i).should('be.visible');
});
it('shows error for invalid credentials', () => {
cy.findByRole('textbox', { name: /email/i }).type('user@example.com');
cy.findByLabelText(/password/i).type('wrongpassword');
cy.findByRole('button', { name: /sign in/i }).click();
cy.findByRole('alert').should('contain', 'Invalid credentials');
cy.url().should('include', '/login');
});
it('redirects to requested page after login', () => {
cy.visit('/dashboard');
// Should redirect to login
cy.url().should('include', '/login');
// Login
cy.findByRole('textbox', { name: /email/i }).type('user@example.com');
cy.findByLabelText(/password/i).type('password123');
cy.findByRole('button', { name: /sign in/i }).click();
// Should redirect back to dashboard
cy.url().should('include', '/dashboard');
});
});
Anti-Patterns
❌ Testing Implementation Details
// WRONG - testing internal state
expect(component.state.isOpen).toBe(true);
// ✅ CORRECT - test user-visible behavior
expect(screen.getByRole('dialog')).toBeVisible();
❌ Using Test IDs Unnecessarily
// WRONG - using test id when accessible query works
screen.getByTestId('submit-button');
// ✅ CORRECT - use accessible query
screen.getByRole('button', { name: /submit/i });