How Do You Test React Components?
Advertisement
🧩 Scenario
Architecture Walkthrough
The Jest + RTL Architecture
Jest is a test runner: it discovers test files, executes them in a Node environment (using jsdom to simulate a browser), provides assertion utilities (expect, toBe, toBeInTheDocument), and reports results. It also handles mocking modules, timing functions, and spying on calls.
React Testing Library is a DOM querying utility. It renders a component into the jsdom environment via render() and provides query functions to find elements: getByRole, getByLabelText, getByText, findByText, and others. RTL's design principle is that tests should mirror how real users find and interact with UI — by visible text, accessible roles, and input labels, not by internal implementation details like component state, class names, or test IDs.
Query Priority
RTL provides several query types. The recommended order from most to least preferred is: getByRole (finds anything with an ARIA role: buttons, inputs, links, headings), getByLabelText (finds form controls by their associated label), getByText (finds elements by visible text), getByPlaceholderText, and getByTestId (last resort only). Querying by role and label validates accessibility as a side effect: if getByLabelText('Email') cannot find the input, the label is missing, which is also an accessibility bug.
getBy vs queryBy vs findBy
All queries have three variants. getBy throws immediately if the element is not found. queryBy returns null if not found (use it to assert absence). findBy returns a Promise that resolves when the element appears or rejects after a timeout (use it for elements that appear after async work). Mixing up the variants is the most common source of flaky async tests.
Key Code Explained
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
// Synchronous test: find elements by role and label, interact, assert
test('shows error when email is invalid', async () => {
const user = userEvent.setup(); // userEvent v14: setup() returns typed instance
render(<LoginForm onSubmit={jest.fn()} />);
// getByLabelText finds the input whose label text is "Email"
// Also validates that the label is correctly associated
await user.type(screen.getByLabelText('Email'), 'not-an-email');
// getByRole('button', { name }) matches by accessible name
await user.click(screen.getByRole('button', { name: /sign in/i }));
// getByText asserts the error message is visible in the DOM
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
// Async test: form submission triggers an API call, state updates after response
test('calls onSubmit with credentials when form is valid', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn().mockResolvedValueOnce({ ok: true });
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText('Email'), 'user@example.com');
await user.type(screen.getByLabelText('Password'), 'secret123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
// waitFor polls until the assertion passes or times out
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'secret123',
});
});
});
// Testing a component with async data fetching
// Mock the module so no real network requests are made
jest.mock('./api', () => ({
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}));
test('displays user name after fetch resolves', async () => {
render(<UserProfile userId={1} />);
// findBy is shorthand for waitFor + getBy
// It retries until the element appears (after the async fetch)
const name = await screen.findByText('Alice');
expect(name).toBeInTheDocument();
});
// Assert an element is NOT present
test('does not show admin panel for regular users', () => {
render(<Dashboard userRole="user" />);
// queryBy returns null instead of throwing — correct for absence checks
expect(screen.queryByRole('region', { name: /admin/i })).not.toBeInTheDocument();
});
The jest.fn() pattern for onSubmit is fundamental. It creates a mock function that records every call, so assertions like expect(mockSubmit).toHaveBeenCalledWith(...) can verify that the component called the prop with the right arguments without needing a real server.
Tradeoffs
| Query method | What it finds | Validates a11y | Recommended for |
|---|---|---|---|
| getByRole | Elements by ARIA role + name | Yes | Buttons, inputs, headings, landmarks |
| getByLabelText | Form controls by label text | Yes | All form inputs |
| getByText | Elements by visible text content | Partially | Paragraphs, spans, static text |
| getByTestId | Elements by data-testid attribute | No | Last resort for elements without accessible identifiers |
What Interviewers Actually Check
- Whether you use
getByRoleandgetByLabelTextas primary queries - Whether you know
userEventoverfireEventand can explain why - Whether you know
findByfor async elements andqueryByfor absence assertions - Whether you avoid testing implementation details (state values, prop names, class names)
- Whether you know how to mock modules and assert on mock function calls
Follow-Up Questions
- How do you test a component that uses
useEffectto fetch data, without making real network requests? - What is
MSW(Mock Service Worker) and how does it differ fromjest.mockfor testing API calls? - How do you test a component wrapped in a Context Provider (e.g., AuthContext, ThemeProvider)?
- How do you test error boundary fallback rendering in React Testing Library?
- What is the difference between a unit test and an integration test in the context of React components?
Common Candidate Mistakes
- Querying by
data-testideverywhere as a convenience shortcut rather than by role or label - Using
fireEvent.changeto type into inputs instead ofuserEvent.type— misses focus events that validation logic depends on - Not awaiting
userEventcalls in v14 (they are async) — tests pass before interactions complete - Writing assertions on component state (
component.state.isLoading) rather than visible DOM changes - Forgetting to wrap act-triggering code (state updates after interactions) properly, then suppressing the warning instead of fixing the test structure
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you explain what Jest does versus what RTL does?
- Can you write a test that renders, interacts via
userEvent, and asserts on DOM output? - Can you explain the query hierarchy and when to use each variant (getBy vs findBy vs queryBy)?
- Can you mock a function prop with
jest.fn()and assert it was called with the right arguments? - Can you handle async assertions with
waitFororfindByand explain the difference?
Summary
Jest is the test runner and assertion library. React Testing Library is a DOM querying utility built on the principle that tests should mirror how users interact with the UI. Query elements by role and label first (which validates accessibility), fall back to text, and use data-testid only when nothing else works. Use userEvent for interactions because it simulates the full sequence of real browser events. Use getBy when an element must be present, queryBy to assert absence, and findBy (or waitFor) for elements that appear after async operations. Avoid testing implementation details: state values, class names, or internal method calls. Tests should pass when the visible behavior is correct and fail when it is not, regardless of how the component is implemented internally.
Why use userEvent instead of fireEvent?
fireEvent dispatches a single synthetic event (e.g., click). userEvent simulates the full sequence of events that real user interaction triggers: pointerover, pointerenter, pointermove, pointerdown, mousedown, focus, pointerup, mouseup, click. For typing, userEvent fires keydown, keypress, input, and keyup for each character. This catches bugs that fireEvent misses because real browser behavior fires multiple events in sequence.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement