Test React components from the user's perspective using React Testing Library: render, query by role and text, fire events, and assert on visible output without testing implementation details.
React Testing Library (RTL) was built around a single philosophy: test your components the way users use them, not the way developers implement them. Users do not care about component state, prop names, or CSS class names. They see text, buttons, headings, and form fields. RTL's query API mirrors this: find elements by their ARIA role, visible label, or placeholder text — never by internal implementation details.
This approach produces tests that survive refactoring. When you rename a state variable or restructure component internals, RTL tests keep passing as long as the visible output is unchanged. When you accidentally break something a user would notice, the test fails. That is exactly the right behaviour.
RTL's render mounts a component into a real DOM (via jsdom) and returns query functions bound to that container.
1// Setup: install dependencies
2// npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom
3
4// jest.config.ts — add setup file
5// setupFilesAfterFramework: ['@testing-library/jest-dom']
6
7// src/components/UserCard.tsx
8interface UserCardProps {
9 name: string;
10 email: string;
11 role: 'admin' | 'student';
12 onEdit: () => void;
13}
14
15export function UserCard({ name, email, role, onEdit }: UserCardProps) {
16 return (
17 <article aria-label={`User card for ${name}`}>
18 <h2>{name}</h2>
19 <p>{email}</p>
20 <span data-testid="user-role">{role}</span>
21 {role === 'admin' && (
22 <button onClick={onEdit}>Edit user</button>
23 )}
24 </article>
25 );
26}
27
28// src/components/UserCard.test.tsx
29import { render, screen } from '@testing-library/react';
30import { UserCard } from './UserCard';
31
32describe('UserCard', () => {
33 const defaultProps = {
34 name: 'Alice Johnson',
35 email: '[email protected]',
36 role: 'admin' as const,
37 onEdit: jest.fn(),
38 };
39
40 it('displays the user name as a heading', () => {
41 render(<UserCard {...defaultProps} />);
42 expect(screen.getByRole('heading', { name: 'Alice Johnson' })).toBeInTheDocument();
43 });
44
45 it('displays the user email', () => {
46 render(<UserCard {...defaultProps} />);
47 expect(screen.getByText('[email protected]')).toBeInTheDocument();
48 });
49
50 it('shows the Edit button for admin users', () => {
51 render(<UserCard {...defaultProps} role="admin" />);
52 expect(screen.getByRole('button', { name: 'Edit user' })).toBeInTheDocument();
53 });
54
55 it('hides the Edit button for student users', () => {
56 render(<UserCard {...defaultProps} role="student" />);
57 expect(screen.queryByRole('button', { name: 'Edit user' })).not.toBeInTheDocument();
58 });
59
60 it('shows the role via data-testid', () => {
61 render(<UserCard {...defaultProps} role="student" />);
62 expect(screen.getByTestId('user-role')).toHaveTextContent('student');
63 });
64});Query hierarchy (prefer higher, fall back to lower):
getByRole — best: mirrors accessibility treegetByLabelText — form fields with labelsgetByPlaceholderText — input placeholdersgetByText — visible textgetByTestId — last resort; use data-testid sparinglyUse @testing-library/user-event over fireEvent for realistic interaction simulation. userEvent dispatches the full sequence of browser events (mousedown, mouseup, click, focus, keydown, keypress, keyup) that a real user generates.
1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import { LoginForm } from './LoginForm';
4
5// src/components/LoginForm.tsx
6export function LoginForm({ onSubmit }: { onSubmit: (email: string, password: string) => void }) {
7 const [email, setEmail] = React.useState('');
8 const [password, setPassword] = React.useState('');
9 const [error, setError] = React.useState('');
10
11 const handleSubmit = (e: React.FormEvent) => {
12 e.preventDefault();
13 if (!email.includes('@')) {
14 setError('Please enter a valid email address');
15 return;
16 }
17 onSubmit(email, password);
18 };
19
20 return (
21 <form onSubmit={handleSubmit}>
22 <label htmlFor="email">Email</label>
23 <input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} />
24
25 <label htmlFor="password">Password</label>
26 <input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} />
27
28 {error && <p role="alert">{error}</p>}
29
30 <button type="submit">Sign in</button>
31 </form>
32 );
33}
34
35// Tests
36describe('LoginForm', () => {
37 it('calls onSubmit with email and password when form is valid', async () => {
38 const onSubmit = jest.fn();
39 const user = userEvent.setup(); // create user instance
40 render(<LoginForm onSubmit={onSubmit} />);
41
42 await user.type(screen.getByLabelText('Email'), '[email protected]');
43 await user.type(screen.getByLabelText('Password'), 'secret123');
44 await user.click(screen.getByRole('button', { name: 'Sign in' }));
45
46 expect(onSubmit).toHaveBeenCalledWith('[email protected]', 'secret123');
47 });
48
49 it('shows a validation error for invalid email', async () => {
50 const user = userEvent.setup();
51 render(<LoginForm onSubmit={jest.fn()} />);
52
53 await user.type(screen.getByLabelText('Email'), 'notanemail');
54 await user.click(screen.getByRole('button', { name: 'Sign in' }));
55
56 expect(screen.getByRole('alert')).toHaveTextContent('valid email address');
57 });
58
59 it('does not call onSubmit when email is invalid', async () => {
60 const onSubmit = jest.fn();
61 const user = userEvent.setup();
62 render(<LoginForm onSubmit={onSubmit} />);
63
64 await user.type(screen.getByLabelText('Email'), 'bad');
65 await user.click(screen.getByRole('button', { name: 'Sign in' }));
66
67 expect(onSubmit).not.toHaveBeenCalled();
68 });
69});When a component fetches data after mounting, assertions must wait for the DOM to update. RTL provides waitFor and findBy queries for this.
1// src/components/UserList.tsx
2export function UserList() {
3 const [users, setUsers] = React.useState<Array<{id: string; name: string}>>([]);
4 const [loading, setLoading] = React.useState(true);
5 const [error, setError] = React.useState('');
6
7 React.useEffect(() => {
8 fetch('/api/users')
9 .then(r => r.json())
10 .then(data => { setUsers(data); setLoading(false); })
11 .catch(() => { setError('Failed to load users'); setLoading(false); });
12 }, []);
13
14 if (loading) return <p>Loading...</p>;
15 if (error) return <p role="alert">{error}</p>;
16
17 return (
18 <ul>
19 {users.map(u => <li key={u.id}>{u.name}</li>)}
20 </ul>
21 );
22}
23
24// Tests
25global.fetch = jest.fn();
26
27describe('UserList', () => {
28 beforeEach(() => jest.clearAllMocks());
29
30 it('shows loading state initially', () => {
31 (global.fetch as jest.Mock).mockReturnValue(new Promise(() => {})); // never resolves
32 render(<UserList />);
33 expect(screen.getByText('Loading...')).toBeInTheDocument();
34 });
35
36 it('renders users after successful fetch', async () => {
37 (global.fetch as jest.Mock).mockResolvedValue({
38 json: async () => [
39 { id: '1', name: 'Alice' },
40 { id: '2', name: 'Bob' },
41 ],
42 });
43 render(<UserList />);
44
45 // findBy* = getBy* + waitFor; automatically waits for element to appear
46 expect(await screen.findByText('Alice')).toBeInTheDocument();
47 expect(screen.getByText('Bob')).toBeInTheDocument();
48 expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
49 });
50
51 it('shows error message when fetch fails', async () => {
52 (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
53 render(<UserList />);
54
55 // waitFor: retries assertion until it passes or times out
56 await waitFor(() => {
57 expect(screen.getByRole('alert')).toHaveTextContent('Failed to load users');
58 });
59 });
60});Query variants:
getBy* — throws immediately if not found (use for synchronous assertions)queryBy* — returns null if not found (use for asserting absence)findBy* — returns Promise; waits up to 1000ms (use for async appearance)Custom hooks encapsulate logic that you want to test without a component wrapper. renderHook from RTL lets you test hooks directly.
1// src/hooks/useCounter.ts
2export function useCounter(initial = 0) {
3 const [count, setCount] = React.useState(initial);
4 const increment = React.useCallback(() => setCount(c => c + 1), []);
5 const decrement = React.useCallback(() => setCount(c => c - 1), []);
6 const reset = React.useCallback(() => setCount(initial), [initial]);
7 return { count, increment, decrement, reset };
8}
9
10// src/hooks/useCounter.test.ts
11import { renderHook, act } from '@testing-library/react';
12import { useCounter } from './useCounter';
13
14describe('useCounter', () => {
15 it('initialises with the given value', () => {
16 const { result } = renderHook(() => useCounter(5));
17 expect(result.current.count).toBe(5);
18 });
19
20 it('defaults to 0 when no initial value is given', () => {
21 const { result } = renderHook(() => useCounter());
22 expect(result.current.count).toBe(0);
23 });
24
25 it('increments the count', () => {
26 const { result } = renderHook(() => useCounter(0));
27 act(() => result.current.increment());
28 expect(result.current.count).toBe(1);
29 });
30
31 it('decrements the count', () => {
32 const { result } = renderHook(() => useCounter(10));
33 act(() => result.current.decrement());
34 expect(result.current.count).toBe(9);
35 });
36
37 it('resets to the initial value', () => {
38 const { result } = renderHook(() => useCounter(5));
39 act(() => result.current.increment());
40 act(() => result.current.increment());
41 act(() => result.current.reset());
42 expect(result.current.count).toBe(5);
43 });
44});Wrap any call that triggers state updates in act(). RTL's userEvent automatically wraps in act, but raw hook calls require it explicitly.
Implementation-detail testing queries by CSS class names, component state, or internal prop names. It produces tests that break when you refactor without changing user-visible behaviour.
1// BAD: testing implementation details
2test('sets isOpen state to true when button is clicked', () => {
3 const wrapper = mount(<Dropdown />); // Enzyme style
4 wrapper.find('.dropdown-trigger').simulate('click');
5 expect(wrapper.state('isOpen')).toBe(true); // INTERNAL STATE — should not test this
6});
7// This test breaks when you rename 'isOpen' to 'open' even though the UI is identical.
8
9// GOOD: testing user-visible behaviour
10test('shows the dropdown menu when the trigger is clicked', async () => {
11 const user = userEvent.setup();
12 render(<Dropdown options={['Option A', 'Option B']} />);
13
14 await user.click(screen.getByRole('button', { name: 'Open menu' }));
15
16 expect(screen.getByRole('listbox')).toBeInTheDocument();
17 expect(screen.getByRole('option', { name: 'Option A' })).toBeInTheDocument();
18});
19// This test survives state variable renaming, CSS class changes, and DOM structure changes.
20
21// GOOD: querying by accessible role instead of CSS
22// BAD: screen.getByClassName('btn-primary')
23// GOOD: screen.getByRole('button', { name: 'Submit' })
24
25// GOOD: asserting on visible text
26// BAD: expect(component.props.errorMessage).toBe('...')
27// GOOD: expect(screen.getByRole('alert')).toHaveTextContent('...')
28
29// jest-dom custom matchers (from @testing-library/jest-dom):
30expect(element).toBeInTheDocument();
31expect(element).toBeVisible();
32expect(element).toBeDisabled();
33expect(element).toHaveTextContent('hello');
34expect(element).toHaveValue('input value');
35expect(element).toHaveAttribute('href', '/profile');
36expect(element).toHaveFocus();A search input component that debounces API calls and renders results, tested with RTL using userEvent, findBy queries, and mock fetch.
1// src/components/CourseSearch.tsx
2import React, { useState, useEffect } from 'react';
3
4interface Course { id: string; title: string; instructor: string; }
5
6interface CourseSearchProps {
7 onSelect: (course: Course) => void;
8}
9
10export function CourseSearch({ onSelect }: CourseSearchProps) {
11 const [query, setQuery] = useState('');
12 const [results, setResults] = useState<Course[]>([]);
13 const [loading, setLoading] = useState(false);
14 const [error, setError] = useState('');
15
16 useEffect(() => {
17 if (query.length < 2) { setResults([]); return; }
18
19 const timer = setTimeout(async () => {
20 setLoading(true);
21 setError('');
22 try {
23 const res = await fetch(`/api/courses/search?q=${encodeURIComponent(query)}`);
24 const data = await res.json();
25 setResults(data);
26 } catch {
27 setError('Search failed. Please try again.');
28 } finally {
29 setLoading(false);
30 }
31 }, 300);
32
33 return () => clearTimeout(timer);
34 }, [query]);
35
36 return (
37 <div>
38 <label htmlFor="course-search">Search courses</label>
39 <input
40 id="course-search"
41 type="search"
42 value={query}
43 onChange={e => setQuery(e.target.value)}
44 placeholder="Type to search..."
45 />
46 {loading && <p aria-live="polite">Searching...</p>}
47 {error && <p role="alert">{error}</p>}
48 {results.length > 0 && (
49 <ul role="listbox" aria-label="Search results">
50 {results.map(course => (
51 <li key={course.id} role="option">
52 <button onClick={() => onSelect(course)}>
53 {course.title} — {course.instructor}
54 </button>
55 </li>
56 ))}
57 </ul>
58 )}
59 </div>
60 );
61}
62
63// src/components/CourseSearch.test.tsx
64import { render, screen, waitFor } from '@testing-library/react';
65import userEvent from '@testing-library/user-event';
66import { CourseSearch } from './CourseSearch';
67
68global.fetch = jest.fn();
69const mockFetch = global.fetch as jest.Mock;
70
71const sampleCourses = [
72 { id: '1', title: 'React Mastery', instructor: 'Alice' },
73 { id: '2', title: 'React Testing', instructor: 'Bob' },
74];
75
76beforeEach(() => {
77 jest.clearAllMocks();
78 jest.useFakeTimers();
79});
80
81afterEach(() => {
82 jest.useRealTimers();
83});
84
85describe('CourseSearch', () => {
86 it('renders the search input with label', () => {
87 render(<CourseSearch onSelect={jest.fn()} />);
88 expect(screen.getByLabelText('Search courses')).toBeInTheDocument();
89 });
90
91 it('does not fetch when query is shorter than 2 characters', async () => {
92 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
93 render(<CourseSearch onSelect={jest.fn()} />);
94
95 await user.type(screen.getByLabelText('Search courses'), 'R');
96 jest.advanceTimersByTime(400);
97
98 expect(mockFetch).not.toHaveBeenCalled();
99 });
100
101 it('shows search results after typing and debounce delay', async () => {
102 mockFetch.mockResolvedValueOnce({
103 json: async () => sampleCourses,
104 });
105 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
106 render(<CourseSearch onSelect={jest.fn()} />);
107
108 await user.type(screen.getByLabelText('Search courses'), 'React');
109 jest.advanceTimersByTime(300);
110
111 expect(await screen.findByText('React Mastery — Alice')).toBeInTheDocument();
112 expect(screen.getByText('React Testing — Bob')).toBeInTheDocument();
113 });
114
115 it('calls onSelect with the course when a result is clicked', async () => {
116 mockFetch.mockResolvedValueOnce({ json: async () => sampleCourses });
117 const onSelect = jest.fn();
118 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
119 render(<CourseSearch onSelect={onSelect} />);
120
121 await user.type(screen.getByLabelText('Search courses'), 'React');
122 jest.advanceTimersByTime(300);
123 await user.click(await screen.findByText('React Mastery — Alice'));
124
125 expect(onSelect).toHaveBeenCalledWith(sampleCourses[0]);
126 });
127
128 it('shows an error message when the fetch fails', async () => {
129 mockFetch.mockRejectedValueOnce(new Error('Network error'));
130 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
131 render(<CourseSearch onSelect={jest.fn()} />);
132
133 await user.type(screen.getByLabelText('Search courses'), 'React');
134 jest.advanceTimersByTime(300);
135
136 await waitFor(() => {
137 expect(screen.getByRole('alert')).toHaveTextContent('Search failed');
138 });
139 });
140});This example tests a realistic async React component: label/input queried by accessible label, debounce timer controlled with fake timers and advanceTimers option on userEvent.setup(), results queried by visible text with findByText (async), click interaction that calls onSelect, and error display verified via role='alert'. Notice there are no CSS class names, no state variable assertions — every assertion is on something a user would actually see or interact with.
The next lesson covers snapshot testing: when jest.toMatchSnapshot() is useful for component output, how to review and update snapshots responsibly, and why snapshot tests should never replace behaviour-based assertions.
Your progress is saved in this browser, on this device only.