Welcome to React Testing Library!
Imagine you're a quality inspector at a car factory. Instead of checking every nut and bolt (implementation details), you test if the car actually drives, the doors open properly, and the radio works when pressed (user interactions). React Testing Library takes the same approach - we test components from the user's perspective, not the internal implementation.
graph TD
A[React Component] --> B{Testing Approach}
B --> C[Implementation Testing]
B --> D[User-Centric Testing]
C --> E[Test State/Props]
C --> F[Test Lifecycle Methods]
C --> G[Test Internal Methods]
D --> H[Test User Interactions]
D --> I[Test Visual Output]
D --> J[Test Accessibility]
style D fill:#99ff99
style H fill:#99ff99
style I fill:#99ff99
style J fill:#99ff99
style C fill:#ff9999
React Testing Library Fundamentals
1. Basic Component Testing
// Component to test
function Greeting({ name = 'Stranger' }) {
return (
<div>
<h1>Hello, {name}!</h1>
<p>Welcome to our application.</p>
</div>
);
}
// Basic test
import { render, screen } from '@testing-library/react';
describe('Greeting', () => {
test('renders greeting with default name', () => {
render(<Greeting />);
// Find elements by text
const heading = screen.getByText('Hello, Stranger!');
expect(heading).toBeInTheDocument();
const welcome = screen.getByText('Welcome to our application.');
expect(welcome).toBeInTheDocument();
});
test('renders greeting with custom name', () => {
render(<Greeting name="John" />);
const heading = screen.getByText('Hello, John!');
expect(heading).toBeInTheDocument();
});
});
// More complex component
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!email || !password) {
setError('All fields are required');
return;
}
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<h2>Login</h2>
{error && <div role="alert">{error}</div>}
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button type="submit">Login</button>
</form>
);
}
// Testing the LoginForm
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
test('renders login form with all fields', () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
expect(screen.getByLabelText('Email:')).toBeInTheDocument();
expect(screen.getByLabelText('Password:')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
});
test('shows error when submitting empty form', () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
fireEvent.click(screen.getByRole('button', { name: 'Login' }));
expect(screen.getByRole('alert')).toHaveTextContent('All fields are required');
expect(handleSubmit).not.toHaveBeenCalled();
});
test('submits form with email and password', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
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(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
});
2. Query Methods
// Different query methods
function QueryExamples() {
return (
<div>
<h1>Query Examples</h1>
<button>Click me</button>
<input type="text" placeholder="Enter text" />
<img src="test.jpg" alt="Test image" />
<label htmlFor="username">Username</label>
<input id="username" />
<div data-testid="custom-element">Custom Element</div>
</div>
);
}
test('demonstrates query methods', () => {
render(<QueryExamples />);
// getBy - throws error if not found
const heading = screen.getByRole('heading', { name: 'Query Examples' });
expect(heading).toBeInTheDocument();
// queryBy - returns null if not found
const missingElement = screen.queryByRole('alert');
expect(missingElement).not.toBeInTheDocument();
// findBy - returns a promise, waits for element
// Useful for async operations
// Different query types:
// By Role (preferred)
const button = screen.getByRole('button', { name: 'Click me' });
// By Label Text
const input = screen.getByLabelText('Username');
// By Placeholder Text
const textInput = screen.getByPlaceholderText('Enter text');
// By Alt Text
const image = screen.getByAltText('Test image');
// By Test ID (last resort)
const customElement = screen.getByTestId('custom-element');
});
// Query priority (from most to least preferred):
// 1. getByRole
// 2. getByLabelText
// 3. getByPlaceholderText
// 4. getByText
// 5. getByDisplayValue
// 6. getByAltText
// 7. getByTitle
// 8. getByTestId
3. User Events
// Testing user interactions
import userEvent from '@testing-library/user-event';
function InteractiveForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
agreeToTerms: false,
country: ''
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<form>
<input
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Your name"
/>
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="Your email"
/>
<select
name="country"
value={formData.country}
onChange={handleChange}
>
<option value="">Select country</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="ca">Canada</option>
</select>
<label>
<input
type="checkbox"
name="agreeToTerms"
checked={formData.agreeToTerms}
onChange={handleChange}
/>
I agree to terms
</label>
<div>
{JSON.stringify(formData, null, 2)}
</div>
</form>
);
}
test('handles form interactions', async () => {
const user = userEvent.setup();
render(<InteractiveForm />);
// Type in input fields
await user.type(screen.getByPlaceholderText('Your name'), 'John Doe');
await user.type(screen.getByPlaceholderText('Your email'), 'john@example.com');
// Select from dropdown
await user.selectOptions(screen.getByRole('combobox'), 'us');
// Click checkbox
await user.click(screen.getByRole('checkbox'));
// Verify form state is updated
expect(screen.getByText(/"name": "John Doe"/)).toBeInTheDocument();
expect(screen.getByText(/"email": "john@example.com"/)).toBeInTheDocument();
expect(screen.getByText(/"country": "us"/)).toBeInTheDocument();
expect(screen.getByText(/"agreeToTerms": true/)).toBeInTheDocument();
});
// Advanced user interactions
function AdvancedInteractions() {
const [clickCount, setClickCount] = useState(0);
const [hoverText, setHoverText] = useState('');
const [keyPressed, setKeyPressed] = useState('');
return (
<div>
<button
onClick={() => setClickCount(c => c + 1)}
onDoubleClick={() => setClickCount(c => c + 10)}
>
Clicks: {clickCount}
</button>
<div
onMouseEnter={() => setHoverText('Mouse entered!')}
onMouseLeave={() => setHoverText('Mouse left!')}
>
Hover me: {hoverText}
</div>
<input
onKeyDown={(e) => setKeyPressed(e.key)}
placeholder="Press a key"
/>
<div>Last key: {keyPressed}</div>
</div>
);
}
test('handles advanced interactions', async () => {
const user = userEvent.setup();
render(<AdvancedInteractions />);
// Single click
await user.click(screen.getByRole('button'));
expect(screen.getByText('Clicks: 1')).toBeInTheDocument();
// Double click
await user.dblClick(screen.getByRole('button'));
expect(screen.getByText('Clicks: 11')).toBeInTheDocument();
// Hover interactions
const hoverDiv = screen.getByText(/Hover me:/);
await user.hover(hoverDiv);
expect(screen.getByText('Mouse entered!')).toBeInTheDocument();
await user.unhover(hoverDiv);
expect(screen.getByText('Mouse left!')).toBeInTheDocument();
// Keyboard interactions
const input = screen.getByPlaceholderText('Press a key');
await user.type(input, 'a');
expect(screen.getByText('Last key: a')).toBeInTheDocument();
});
Testing Asynchronous Code
1. Async Data Fetching
// Component that fetches data
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUser() {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
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>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
</div>
);
}
// Testing async component
import { render, screen, waitFor } from '@testing-library/react';
describe('UserProfile', () => {
beforeEach(() => {
// Reset fetch mocks before each test
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
test('shows loading state initially', () => {
render(<UserProfile userId="1" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('displays user data when fetch succeeds', async () => {
const mockUser = {
id: '1',
name: 'John Doe',
email: 'john@example.com',
role: 'admin'
};
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser
});
render(<UserProfile userId="1" />);
// Wait for the data to load
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('Email: john@example.com')).toBeInTheDocument();
expect(screen.getByText('Role: admin')).toBeInTheDocument();
});
test('displays error when fetch fails', async () => {
global.fetch.mockRejectedValueOnce(new Error('Network error'));
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('Error: Network error')).toBeInTheDocument();
});
});
});
2. Testing with Mock Service Worker (MSW)
// Setting up MSW
import { rest } from 'msw';
import { setupServer } from 'msw/node';
// Define handlers
const handlers = [
rest.get('/api/users/:userId', (req, res, ctx) => {
const { userId } = req.params;
if (userId === '1') {
return res(
ctx.json({
id: '1',
name: 'John Doe',
email: 'john@example.com'
})
);
}
return res(
ctx.status(404),
ctx.json({ message: 'User not found' })
);
}),
rest.post('/api/login', async (req, res, ctx) => {
const { email, password } = await req.json();
if (email === 'test@example.com' && password === 'password') {
return res(
ctx.json({
token: 'fake-jwt-token',
user: { id: '1', email }
})
);
}
return res(
ctx.status(401),
ctx.json({ message: 'Invalid credentials' })
);
})
];
// Setup server
const server = setupServer(...handlers);
// Start server before all tests
beforeAll(() => server.listen());
// Reset handlers after each test
afterEach(() => server.resetHandlers());
// Clean up after all tests
afterAll(() => server.close());
// Testing with MSW
function LoginForm() {
const [status, setStatus] = useState('idle');
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('loading');
const formData = new FormData(e.target);
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.get('email'),
password: formData.get('password')
})
});
const data = await response.json();
if (!response.ok) throw new Error(data.message);
setStatus('success');
localStorage.setItem('token', data.token);
} catch (err) {
setStatus('error');
setError(err.message);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Logging in...' : 'Login'}
</button>
{status === 'error' && <div role="alert">{error}</div>}
{status === 'success' && <div>Login successful!</div>}
</form>
);
}
test('handles successful login', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password');
await user.click(screen.getByRole('button', { name: /login/i }));
await waitFor(() => {
expect(screen.getByText('Login successful!')).toBeInTheDocument();
});
expect(localStorage.getItem('token')).toBe('fake-jwt-token');
});
test('handles login failure', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'wrong@example.com');
await user.type(screen.getByLabelText(/password/i), 'wrongpassword');
await user.click(screen.getByRole('button', { name: /login/i }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
});
});
3. Testing Timers and Delays
// Component with timers
function Notification({ message, duration = 3000 }) {
const [visible, setVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setVisible(false);
}, duration);
return () => clearTimeout(timer);
}, [duration]);
if (!visible) return null;
return (
<div role="alert" className="notification">
{message}
</div>
);
}
// Testing with fake timers
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
test('notification disappears after duration', () => {
render(<Notification message="Test message" duration={3000} />);
// Notification should be visible initially
expect(screen.getByRole('alert')).toBeInTheDocument();
// Fast-forward time
act(() => {
jest.advanceTimersByTime(3000);
});
// Notification should be gone
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
// Component with intervals
function Counter() {
const [count, setCount] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
let interval;
if (isRunning) {
interval = setInterval(() => {
setCount(c => c + 1);
}, 1000);
}
return () => clearInterval(interval);
}, [isRunning]);
return (
<div>
<div>Count: {count}</div>
<button onClick={() => setIsRunning(!isRunning)}>
{isRunning ? 'Stop' : 'Start'}
</button>
</div>
);
}
test('counter increments when running', async () => {
const user = userEvent.setup({ delay: null });
render(<Counter />);
// Initially count is 0
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// Start the counter
await user.click(screen.getByRole('button', { name: 'Start' }));
// Advance timers by 3 seconds
act(() => {
jest.advanceTimersByTime(3000);
});
// Count should be 3
expect(screen.getByText('Count: 3')).toBeInTheDocument();
// Stop the counter
await user.click(screen.getByRole('button', { name: 'Stop' }));
// Advance time again
act(() => {
jest.advanceTimersByTime(2000);
});
// Count should still be 3
expect(screen.getByText('Count: 3')).toBeInTheDocument();
});
Testing Custom Hooks
1. Basic Hook Testing
// Custom hook
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// Testing custom hooks
import { renderHook, act } from '@testing-library/react';
describe('useCounter', () => {
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 counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
2. Complex Hook Testing
// Complex hook with API calls
function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchUser = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [userId]);
useEffect(() => {
fetchUser();
}, [fetchUser]);
return { user, loading, error, refetch: fetchUser };
}
// Testing complex hooks
describe('useUser', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
test('fetches user successfully', async () => {
const mockUser = { id: 1, name: 'John Doe' };
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser
});
const { result } = renderHook(() => useUser(1));
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.user).toBe(null);
// Wait for fetch to complete
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toEqual(mockUser);
expect(result.current.error).toBe(null);
});
test('handles fetch error', async () => {
global.fetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useUser(1));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe('Network error');
expect(result.current.user).toBe(null);
});
test('refetches data when userId changes', async () => {
const mockUser1 = { id: 1, name: 'User 1' };
const mockUser2 = { id: 2, name: 'User 2' };
global.fetch
.mockResolvedValueOnce({
ok: true,
json: async () => mockUser1
})
.mockResolvedValueOnce({
ok: true,
json: async () => mockUser2
});
const { result, rerender } = renderHook(
({ userId }) => useUser(userId),
{ initialProps: { userId: 1 } }
);
// Wait for first fetch
await waitFor(() => {
expect(result.current.user).toEqual(mockUser1);
});
// Change userId
rerender({ userId: 2 });
// Wait for second fetch
await waitFor(() => {
expect(result.current.user).toEqual(mockUser2);
});
});
});
Integration Testing
1. Testing Component Interaction
// Shopping cart components
function Product({ product, onAddToCart }) {
return (
<div>
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => onAddToCart(product)}>
Add to Cart
</button>
</div>
);
}
function Cart({ items, onRemove }) {
const total = items.reduce((sum, item) => sum + item.price, 0);
return (
<div>
<h2>Shopping Cart</h2>
{items.length === 0 ? (
<p>Your cart is empty</p>
) : (
<>
<ul>
{items.map(item => (
<li key={item.id}>
{item.name} - ${item.price}
<button onClick={() => onRemove(item.id)}>
Remove
</button>
</li>
))}
</ul>
<p>Total: ${total}</p>
</>
)}
</div>
);
}
function ShoppingApp() {
const [cart, setCart] = useState([]);
const products = [
{ id: 1, name: 'Product 1', price: 10 },
{ id: 2, name: 'Product 2', price: 20 },
{ id: 3, name: 'Product 3', price: 30 }
];
const addToCart = (product) => {
setCart(prev => [...prev, product]);
};
const removeFromCart = (productId) => {
setCart(prev => prev.filter(item => item.id !== productId));
};
return (
<div>
<h1>Shopping App</h1>
<div>
{products.map(product => (
<Product
key={product.id}
product={product}
onAddToCart={addToCart}
/>
))}
</div>
<Cart items={cart} onRemove={removeFromCart} />
</div>
);
}
// Integration test
test('shopping cart functionality', async () => {
const user = userEvent.setup();
render(<ShoppingApp />);
// Initially cart is empty
expect(screen.getByText('Your cart is empty')).toBeInTheDocument();
// Add products to cart
const addButtons = screen.getAllByRole('button', { name: 'Add to Cart' });
await user.click(addButtons[0]); // Add Product 1
await user.click(addButtons[1]); // Add Product 2
// Check if products are in cart
expect(screen.getByText('Product 1 - $10')).toBeInTheDocument();
expect(screen.getByText('Product 2 - $20')).toBeInTheDocument();
expect(screen.getByText('Total: $30')).toBeInTheDocument();
// Remove a product
const removeButtons = screen.getAllByRole('button', { name: 'Remove' });
await user.click(removeButtons[0]); // Remove Product 1
// Check if cart is updated
expect(screen.queryByText('Product 1 - $10')).not.toBeInTheDocument();
expect(screen.getByText('Product 2 - $20')).toBeInTheDocument();
expect(screen.getByText('Total: $20')).toBeInTheDocument();
});
2. Testing with Context
// Theme context and components
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
function ThemedButton() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<button
onClick={toggleTheme}
style={{
background: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#333' : '#fff'
}}
>
Current theme: {theme}
</button>
);
}
// Testing with context
test('theme toggle functionality', async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<ThemedButton />
</ThemeProvider>
);
const button = screen.getByRole('button');
// Initially light theme
expect(button).toHaveTextContent('Current theme: light');
expect(button).toHaveStyle({ background: '#fff', color: '#333' });
// Toggle to dark theme
await user.click(button);
expect(button).toHaveTextContent('Current theme: dark');
expect(button).toHaveStyle({ background: '#333', color: '#fff' });
// Toggle back to light theme
await user.click(button);
expect(button).toHaveTextContent('Current theme: light');
expect(button).toHaveStyle({ background: '#fff', color: '#333' });
});
// Test utility for wrapping with providers
function renderWithProviders(ui, { providerProps, ...renderOptions } = {}) {
return render(
<ThemeProvider {...providerProps}>
{ui}
</ThemeProvider>,
renderOptions
);
}
// Using the utility
test('component with theme context', () => {
renderWithProviders(<ThemedButton />);
// ... test implementation
});
Testing Best Practices
1. Test User Behavior, Not Implementation
// ❌ Bad: Testing implementation details
test('calls setState when button clicked', () => {
const setStateSpy = jest.spyOn(React, 'useState');
// Don't do this!
});
// ✅ Good: Testing user behavior
test('increments counter when button clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: '+' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
2. Use Meaningful Queries
// ❌ Bad: Using test IDs everywhere
<button data-testid="submit-button">Submit</button>
screen.getByTestId('submit-button');
// ✅ Good: Using accessible queries
<button>Submit</button>
screen.getByRole('button', { name: 'Submit' });
// Accessibility-first queries (in order of preference):
// 1. getByRole
// 2. getByLabelText
// 3. getByPlaceholderText
// 4. getByText
// 5. getByDisplayValue
// 6. getByAltText
// 7. getByTitle
// 8. getByTestId (last resort)
3. Avoid Implementation Details
// ❌ Bad: Testing component state
test('sets isLoading to true', () => {
// Don't test internal state!
});
// ✅ Good: Testing visible behavior
test('shows loading spinner while fetching', async () => {
render(<UserProfile />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});
4. Write Maintainable Tests
// Create test utilities
const setup = (props = {}) => {
const user = userEvent.setup();
const utils = render(<LoginForm {...props} />);
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /login/i });
return {
user,
emailInput,
passwordInput,
submitButton,
...utils
};
};
// Use utilities in tests
test('successful login', async () => {
const { user, emailInput, passwordInput, submitButton } = setup();
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'password');
await user.click(submitButton);
expect(screen.getByText('Welcome!')).toBeInTheDocument();
});
Practice Exercises
Exercise 1: Test a Todo Application
Create tests for a todo list app that:
- Adds new todos
- Marks todos as complete
- Filters todos (all, active, completed)
- Deletes todos
- Persists todos to localStorage
Exercise 2: Test a Form with Validation
Write tests for a registration form with:
- Email validation
- Password strength requirements
- Matching password confirmation
- Async username availability check
- Form submission
Exercise 3: Test a Data Table
Create tests for a data table component with:
- Sorting by different columns
- Pagination
- Search/filter functionality
- Row selection
- Bulk actions
Key Takeaways
- React Testing Library focuses on testing user behavior, not implementation
- Use accessible queries (getByRole, getByLabelText) over test IDs
- Test asynchronous operations with waitFor and findBy queries
- Use userEvent for more realistic user interactions
- Mock API calls with MSW or jest.fn()
- Test custom hooks with renderHook
- Write integration tests for component interactions