βοΈ React Component Testing
Components are what your users actually touch, so they deserve tests that behave like a user. This lesson uses React Testing Library and Jest to render components, query them the accessible way, simulate real interactions, mock the network, and test custom hooks.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain the Testing Library philosophy and why it favours accessible queries
- Render a component and query it by role, label, and text in priority order
- Simulate interactions with
user-eventand choose it overfireEvent - Test asynchronous components and mock
fetchor use Mock Service Worker - Test a custom hook with
renderHookandact
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Write a full test suite for a Todo list component.
In This Lesson
Why Test Components?
Components are the building blocks of your interface β the parts a user sees, reads, and clicks. Testing them well gives you five concrete wins:
- Correctness β they render the right thing under different props and states.
- Reliability β they respond correctly to clicks, typing, and keyboard use.
- Regression safety β a change in one place doesn't silently break another.
- Accessibility β if a test can find an element by its role, so can a screen reader.
- Documentation β a readable test is the clearest spec of how a component should behave.
π‘ An analogy: Testing components is quality control on the assembly line. You check each part in isolation before you bolt the whole car together β cheaper to catch a faulty bracket now than after the vehicle ships.
The Testing Library Philosophy
"The more your tests resemble the way your software is used, the more confidence they can give you." β Kent C. Dodds, creator of Testing Library
That single sentence drives every design decision in React Testing Library (RTL). Instead of poking at component internals β state variables, method names, instance properties β you interact with the rendered DOM the way a person would.
π Guiding principles
Test behaviour, not implementation. Assert on what the user sees, not how the component stores it.
Find elements as a user would. Prefer accessible roles, labels, and visible text over test IDs.
Accessibility first. A component that's hard to query is usually hard to use with assistive tech, too.
Tools & Setup
A modern React test stack has a small, well-defined set of packages:
| Package | Role |
|---|---|
@testing-library/react | Render components and query the DOM (also ships renderHook) |
@testing-library/jest-dom | Readable DOM matchers like toBeInTheDocument() |
@testing-library/user-event | Realistic user interaction simulation |
jest / vitest + jsdom | Test runner and a simulated browser environment |
msw | Mock Service Worker β mocks the network at the request level |
npm install --save-dev @testing-library/react @testing-library/jest-dom \
@testing-library/user-event jest jest-environment-jsdom
Point Jest at a setup file so the DOM matchers are available in every test:
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};
// jest.setup.js
import '@testing-library/jest-dom';
π‘ Already using Vite?
Vite projects usually pair with Vitest instead of Jest. The Testing Library API is identical; only the runner config differs. Everything in this lesson works unchanged under Vitest.
Querying Elements
RTL queries come in three variants that differ in how they handle a missing element:
getByβ¦β returns the element, or throws if it's absent (use for things that must exist now).queryByβ¦β returns the element ornull(use to assert absence).findByβ¦β returns a Promise that resolves when the element appears (use for async UI).
Each has an β¦AllByβ¦ plural that returns an array.
Query methods, in priority order
Prefer queries near the top β they mirror how users and assistive technology find things. Drop to getByTestId only as a last resort.
// 1. Accessible to everyone β strongly preferred
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email/i);
screen.getByPlaceholderText(/search/i);
screen.getByText(/welcome back/i);
// 2. Semantic queries
screen.getByAltText(/profile picture/i);
screen.getByTitle(/close/i);
// 3. Escape hatch β avoid unless nothing above works
screen.getByTestId('custom-widget');
// Asserting absence uses queryBy (returns null, doesn't throw)
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
π‘ An analogy: Identifying an element is like finding a person in a crowd. You'd call out their role ("the chef!"), or their name, or what they're wearing before resorting to a secret badge number you assigned. The most natural identifier should win.
Testing Interactions
RTL offers two ways to trigger events. fireEvent dispatches a single raw DOM event; user-event simulates the full sequence a real user produces (focus, keydown, keypress, input, keyupβ¦). Prefer user-event β it catches bugs that fireEvent misses.
The preferred approach: user-event
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
test('input updates as the user types', async () => {
const user = userEvent.setup(); // call setup() once per test
render(<input aria-label="name" />);
const input = screen.getByLabelText(/name/i);
await user.type(input, 'Hello World'); // fires per-character events
expect(input).toHaveValue('Hello World');
});
The common interactions, all returning promises you should await:
await user.click(button);
await user.dblClick(button);
await user.type(input, 'Hello');
await user.clear(input);
await user.keyboard('{Enter}');
await user.selectOptions(select, ['option1']);
await user.tab();
β οΈ Always await user-event calls
Since v14, every user-event action is async. Forgetting await leads to assertions that run before the interaction finishes β the classic "my test passes but the app is broken" trap.
Worked Example: A Signup Form
Here's a form with client-side validation. Note the accessible markup β every input has a <label>, and errors use role="alert" β which makes it a joy to test.
// SignupForm.jsx
import { useState } from 'react';
export default function SignupForm({ onSubmit }) {
const [form, setForm] = useState({ username: '', email: '', password: '' });
const [errors, setErrors] = useState({});
const handleChange = (e) =>
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
const validate = () => {
const next = {};
if (!form.username) next.username = 'Username is required';
if (!form.email) next.email = 'Email is required';
else if (!/\S+@\S+\.\S+/.test(form.email)) next.email = 'Email is invalid';
if (!form.password) next.password = 'Password is required';
else if (form.password.length < 6) next.password = 'Password must be at least 6 characters';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validate()) onSubmit(form);
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">Username</label>
<input id="username" name="username" value={form.username} onChange={handleChange} />
{errors.username && <span role="alert">{errors.username}</span>}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" value={form.email} onChange={handleChange} />
{errors.email && <span role="alert">{errors.email}</span>}
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" value={form.password} onChange={handleChange} />
{errors.password && <span role="alert">{errors.password}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
The test suite reads like a checklist of user stories:
// SignupForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SignupForm from './SignupForm';
describe('SignupForm', () => {
test('renders every field and the submit button', () => {
render(<SignupForm onSubmit={() => {}} />);
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign up/i })).toBeInTheDocument();
});
test('shows required errors on empty submit', async () => {
const user = userEvent.setup();
render(<SignupForm onSubmit={() => {}} />);
await user.click(screen.getByRole('button', { name: /sign up/i }));
expect(screen.getByText(/username is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
test('flags an invalid email format', async () => {
const user = userEvent.setup();
render(<SignupForm onSubmit={() => {}} />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /sign up/i }));
expect(screen.getByText(/email is invalid/i)).toBeInTheDocument();
});
test('calls onSubmit with the form data when valid', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<SignupForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/username/i), 'testuser');
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign up/i }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
username: 'testuser',
email: 'test@example.com',
password: 'password123',
});
});
});
β Notice what we did not do
We never read form state or spied on validate. We drove the component through its labels and button and asserted on visible errors and the submit callback β exactly what a user experiences. That's why this suite survives a refactor of the internals.
Testing Async Components
Many components fetch data on mount. To test them, mock the network and then wait for the UI to settle using findBy⦠or waitFor.
// UserProfile.jsx
import { useState, useEffect } from 'react';
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let active = true;
(async () => {
try {
setLoading(true);
const res = await fetch(`https://api.example.com/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
const data = await res.json();
if (active) setUser(data);
} catch (err) {
if (active) setError(err.message);
} finally {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, [userId]);
if (loading) return <div>Loading user data...</div>;
if (error) return <div role="alert">Error: {error}</div>;
if (!user) return <div>No user found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
</div>
);
}
Option A β mock fetch directly
// UserProfile.test.jsx
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => jest.restoreAllMocks());
test('renders user data when the fetch succeeds', async () => {
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: '123', name: 'John Doe', email: 'john@example.com' }),
});
render(<UserProfile userId="123" />);
// findBy waits for the async render to complete
expect(await screen.findByRole('heading', { name: /john doe/i })).toBeInTheDocument();
expect(screen.getByText(/john@example.com/i)).toBeInTheDocument();
});
test('shows an error when the fetch fails', async () => {
global.fetch.mockResolvedValueOnce({ ok: false });
render(<UserProfile userId="123" />);
expect(await screen.findByRole('alert')).toHaveTextContent(/failed to fetch user/i);
});
Option B β Mock Service Worker (recommended)
Mocking fetch works, but MSW intercepts requests at the network layer, so your component code β and even the real fetch β runs untouched. This is the modern MSW v2 API (http + HttpResponse):
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('https://api.example.com/users/:userId', ({ params }) => {
if (params.userId === '123') {
return HttpResponse.json({ id: '123', name: 'John Doe', email: 'john@example.com' });
}
return new HttpResponse(null, { status: 404 });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
π‘ findBy vs. waitFor
Use findByRole/findByText when you're waiting for an element to appear β it's the cleanest option. Reach for waitFor(() => expect(...)) when you need to wait for an arbitrary condition, and waitForElementToBeRemoved to assert a spinner has disappeared.
Testing Custom Hooks
You can't call a hook outside a component, so RTL provides renderHook. Wrap any state update that happens outside an event in act so React flushes it before you assert.
// useCounter.js
import { useState, useCallback } from 'react';
export default function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => c + 1), []);
const decrement = useCallback(() => setCount((c) => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, decrement, reset };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
test('starts at the default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('honours an initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments and decrements', () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.increment());
expect(result.current.count).toBe(6);
act(() => result.current.decrement());
expect(result.current.count).toBe(5);
});
test('resets to the initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(7);
act(() => result.current.reset());
expect(result.current.count).toBe(5);
});
});
Best Practices
β Do
- Query by role and label first. Accessible queries double as accessibility checks.
- Use
user-eventfor interactions, and alwaysawaitit. - Test all the states: loading, success, error, and empty.
- Mock at the network boundary with MSW so your component code runs for real.
- Assert with jest-dom matchers like
toBeInTheDocumentandtoBeDisabledfor readable failures.
β οΈ Avoid
- Testing implementation details β internal state, instance methods, or CSS class names.
- Overusing
data-testidwhen a role or label would do. - Forgetting to await async interactions and queries β a top source of flaky tests.
- Sharing state between tests; render fresh in each one.
π‘ From the field: Teams that follow RTL's behaviour-first approach report that a UI rewrite barely touches their tests, whereas suites glued to implementation details often have to be thrown away and rewritten. Resilience is the whole point.
Hands-on Exercise
ποΈ Test a Todo list component
Objective: Write a full suite for the component below, driving it entirely through accessible queries and user-event.
// TodoList.jsx
import { useState } from 'react';
export default function TodoList() {
const [todos, setTodos] = useState([]);
const [value, setValue] = useState('');
const add = () => {
if (!value.trim()) return;
setTodos((t) => [...t, { id: Date.now(), text: value, done: false }]);
setValue('');
};
const toggle = (id) =>
setTodos((t) => t.map((x) => (x.id === id ? { ...x, done: !x.done } : x)));
const remove = (id) => setTodos((t) => t.filter((x) => x.id !== id));
return (
<div>
<h2>Todo List</h2>
<label htmlFor="new-todo">New todo</label>
<input id="new-todo" value={value} onChange={(e) => setValue(e.target.value)} />
<button onClick={add}>Add</button>
{todos.length === 0 ? (
<p>No todos yet. Add one above!</p>
) : (
<ul>
{todos.map((t) => (
<li key={t.id}>
<input type="checkbox" checked={t.done}
onChange={() => toggle(t.id)}
aria-label={`Toggle ${t.text}`} />
<span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>{t.text}</span>
<button onClick={() => remove(t.id)} aria-label={`Delete ${t.text}`}>Delete</button>
</li>
))}
</ul>
)}
</div>
);
}
Verify that:
- The empty state shows "No todos yet".
- Typing a todo and clicking Add makes it appear in the list.
- Toggling the checkbox applies line-through styling.
- Clicking Delete removes the item.
π‘ Hint
Query the input with getByLabelText(/new todo/i) and the checkboxes/delete buttons via their aria-labels (e.g. getByRole('checkbox', { name: /toggle buy milk/i })). Use queryByText to assert the empty message is gone after adding.
β Sample solution
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';
test('shows the empty state initially', () => {
render(<TodoList />);
expect(screen.getByText(/no todos yet/i)).toBeInTheDocument();
});
test('adds a todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.type(screen.getByLabelText(/new todo/i), 'Buy milk');
await user.click(screen.getByRole('button', { name: /add/i }));
expect(screen.getByText('Buy milk')).toBeInTheDocument();
expect(screen.queryByText(/no todos yet/i)).not.toBeInTheDocument();
});
test('toggles and deletes a todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.type(screen.getByLabelText(/new todo/i), 'Buy milk');
await user.click(screen.getByRole('button', { name: /add/i }));
await user.click(screen.getByRole('checkbox', { name: /toggle buy milk/i }));
expect(screen.getByText('Buy milk')).toHaveStyle('text-decoration: line-through');
await user.click(screen.getByRole('button', { name: /delete buy milk/i }));
expect(screen.queryByText('Buy milk')).not.toBeInTheDocument();
});
π― Quick Quiz
Question 1: Which query should you use to assert that an element is not in the document?
Question 2: Why is user-event preferred over fireEvent?
Question 3: Which helper renders and tests a custom hook in isolation?
Summary & Quiz
π Key Takeaways
- Test behaviour, not implementation β the Testing Library creed.
- Query by role, label, and text before ever reaching for
data-testid. - Use
user-eventfor interactions and alwaysawaitit. - Test async UI with
findBy/waitFor, and mock the network with MSW. - Test custom hooks with
renderHook, wrapping updates inact.
π Further Reading
- React Testing Library β official docs
- Testing Library β query priority guide
- Mock Service Worker β API mocking
π What's Next?
Now that you can test single components and hooks, we'll zoom out to integration testing β verifying that many components, contexts, and services work together across a complete user flow.
π Great work!
You can render, query, interact, and mock like a user. Your components now have a safety net that survives refactors.