React 84 ⚛️ Unit Testing Components with React Testing Library
React Testing Library (RTL) is not a test runner. It is a set of utilities that render a React component into a DOM and provide queries for finding elements the way a user would find them—by role, by label, by text—rather than by implementation details like component state or class names . The library’s guiding principle is explicit: “The more your tests resemble the way your software is used, the more confidence they can give you.” This principle is the reason RTL replaced Enzyme as the standard for React component testing. Enzyme’s shallow rendering and its ability to inspect internal state encouraged tests that broke when the component was refactored, even when the user-facing behavior was unchanged. RTL renders the full DOM and queries it through accessible selectors, so a refactor that preserves the output does not break the test .
The library has two layers. @testing-library/react provides the React-specific render function and the screen object. @testing-library/dom, which is a separate peer dependency since RTL 16, provides the query engine underneath . The queries are the heart of the library: getByRole, getByLabelText, getByText, and their queryBy and findBy variants. The variant determines the failure mode. getBy throws when no match is found. queryBy returns null and is used for asserting absence. findBy returns a Promise and retries until the element appears or the timeout expires, making it the correct tool for asynchronous rendering .
Key point: RTL renders the component into a real DOM and queries it through accessible selectors. Use getByRole first, getByLabelText for form fields, and getByTestId only as an escape hatch. Use getBy for elements that must be present now, queryBy for elements that must be absent, and findBy for elements that appear after async work. Prefer userEvent over fireEvent because it simulates the full interaction sequence rather than a single event.
Why RTL replaced Enzyme
The implementation-detail problem. Enzyme’s shallow renderer allowed tests to inspect a component’s props and state without rendering its children. This made tests fast, but it also made them brittle. A test that asserted wrapper.state('isOpen') === true broke when the state was moved to a context or a reducer, even though the dropdown still opened when the user clicked the button . RTL’s philosophy is that the user does not know or care about the state variable; the user cares that the dialog is visible. A test that asserts screen.getByRole('dialog') is present survives the refactor.
The accessibility problem. RTL’s query priority is designed to push developers toward accessible markup. getByRole queries the accessibility tree, which is what screen readers use. If a button has no accessible name, getByRole('button', { name: /submit/i }) fails, and the developer is forced to add an aria-label or visible text. The test failure becomes an accessibility improvement . The Enzyme test that found the button by its CSS class did not have this effect.
The async problem. Components fetch data. The initial render shows a loading state, and the data appears after a promise resolves. RTL’s findBy queries and waitFor utility handle this natively. The test awaits screen.findByText('Loaded'), and the query retries until the element appears or the default 1000ms timeout expires . Enzyme’s synchronous API required manual setTimeout and wrapper.update() calls, which were fragile and slow.
a. The render function and the screen object
The render function mounts the component into a container that is appended to document.body. It returns an object with the container, an unmount function, a rerender function, and the bound queries. The screen object is the recommended way to access those queries; it is bound to document.body and does not require destructuring .
// Import render and screen from RTL
import { render, screen } from '@testing-library/react'
// Import the component under test
import { Greeting } from './Greeting'
test('renders the greeting', () => {
// Render the component into the DOM
render(<Greeting name="Alice" />)
// Query the DOM through screen
// getByRole finds the heading by its accessible role and name
const heading = screen.getByRole('heading', { name: /hello, alice/i })
// Assert that the heading is in the document
// toBeInTheDocument comes from @testing-library/jest-dom
expect(heading).toBeInTheDocument()
})
The screen.debug() call is the debugging tool. It prints the current DOM, formatted, to the console. When a query fails, the error message includes the DOM, but screen.debug() is the explicit way to see what RTL “sees” after a render or an interaction .
The render result also has a rerender function for testing prop changes without unmounting the component. This is useful for components that should preserve internal state across prop updates .
b. The query priority and the variants
RTL defines a strict priority for queries, and the order is not arbitrary. The top priority is getByRole, because it queries the accessibility tree—the same tree that assistive technology uses. The second priority is getByLabelText for form fields, because that is how users find inputs. getByText is for non-interactive content. getByTestId is the last resort, because the user cannot see or hear a data-testid attribute .
// Priority 1: by role
screen.getByRole('button', { name: /submit/i })
screen.getByRole('textbox', { name: /email/i })
// Priority 2: by label for form fields
screen.getByLabelText(/password/i)
// Priority 3: by text for non-interactive content
screen.getByText(/welcome back/i)
// Priority 4: by alt text for images
screen.getByAltText(/company logo/i)
// Last resort: by test id
screen.getByTestId('custom-widget')
The variant determines the failure mode. getBy throws if no match or more than one match is found. queryBy returns null if no match is found and is the correct choice for asserting that an element is absent. findBy returns a Promise that resolves when the element appears, retrying until the timeout. getAllBy and findAllBy return arrays for multiple matches .
| Variant | 0 matches | 1 match | >1 matches | Async |
|---|---|---|---|---|
getBy | throws | returns | throws | No |
queryBy | null | returns | throws | No |
findBy | throws (after timeout) | returns | throws | Yes |
The findBy variant is the correct tool for async rendering. The test does not need waitFor and getBy; await screen.findByText('Loaded') is simpler and produces a better failure message when the element never appears .
c. userEvent, fireEvent, and the async interaction
fireEvent dispatches a single DOM event. fireEvent.click(button) fires a click event and nothing else. A real click involves focusing the element, pressing the mouse button, releasing it, and firing the associated events. fireEvent does not simulate that sequence. userEvent does. It is the companion library that “simulates user interactions by dispatching the events that would happen if the interaction took place in a browser” .
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('submits the form', async () => {
// Set up the user session before rendering
const user = userEvent.setup()
render(<LoginForm />)
// Type into the email field
// user.type focuses the field, fires keydown, keypress, keyup, and input
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
// Click the submit button
await user.click(screen.getByRole('button', { name: /submit/i }))
// Assert that the success message appears
expect(await screen.findByText(/welcome/i)).toBeInTheDocument()
})
The userEvent.setup() call is required since version 14. The user methods return Promises, so the test must be async and the calls must be awaited. A test that calls user.click() without await will fail because the assertion runs before the event handlers complete .
fireEvent is still useful for cases that userEvent does not cover—a specific event that the component listens for, or a low-level interaction that the user simulation does not model. But for the common interactions (click, type, select, keyboard), userEvent is the correct choice .
Complete Example Session
The following session builds a login form test with RTL, covering rendering, user interaction, async submission, and error handling.
// ============================================
// PART 1: THE COMPONENT UNDER TEST
// ============================================
import { useState } from 'react'
export function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
setError('')
setLoading(true)
try {
await onSubmit({ email, password })
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div role="alert">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
)
}
// ============================================
// PART 2: THE TEST FILE
// ============================================
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
test('submits the form with email and password', async () => {
// Set up the user session
const user = userEvent.setup()
// A mock submit handler that resolves
const onSubmit = jest.fn().mockResolvedValue()
render(<LoginForm onSubmit={onSubmit} />)
// Fill in the email field by its label
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
// Fill in the password field by its label
await user.type(screen.getByLabelText(/password/i), 'secret123')
// Click the submit button by its role and name
await user.click(screen.getByRole('button', { name: /sign in/i }))
// Assert that the handler was called with the expected values
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
})
})
test('shows an error when submission fails', async () => {
const user = userEvent.setup()
// A mock that rejects with an error
const onSubmit = jest.fn().mockRejectedValue(new Error('Invalid credentials'))
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
await user.type(screen.getByLabelText(/password/i), 'wrong')
await user.click(screen.getByRole('button', { name: /sign in/i }))
// findByRole waits for the alert to appear
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid credentials')
})
test('disables the button while submitting', async () => {
const user = userEvent.setup()
// A promise that never resolves, to keep the form in loading state
const onSubmit = jest.fn(() => new Promise(() => {}))
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
await user.type(screen.getByLabelText(/password/i), 'secret')
await user.click(screen.getByRole('button', { name: /sign in/i }))
// The button text changes to "Signing in..." and is disabled
expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled()
})
The three parts cover the component, the success test, and the error and loading tests. What the session demonstrates is that the queries are accessible—getByLabelText for the inputs, getByRole for the button and the alert—and the interaction is simulated with userEvent, which is the realistic replacement for fireEvent.
Quick Reference
Query Priority
| Priority | Query | Use For |
|---|---|---|
| 1 | getByRole | Buttons, headings, links, form elements |
| 2 | getByLabelText | Form inputs |
| 3 | getByPlaceholderText | When no label exists |
| 4 | getByText | Non-interactive content |
| 5 | getByDisplayValue | Filled form fields |
| 6 | getByAltText | Images |
| 7 | getByTitle | Title attribute |
| 8 | getByTestId | Last resort |
Query Variants
| Variant | No Match | Match | Async |
|---|---|---|---|
getBy | Throws | Returns | No |
queryBy | null | Returns | No |
findBy | Throws (timeout) | Returns | Yes |
getAllBy | Throws | Array | No |
queryAllBy | [] | Array | No |
findAllBy | Throws (timeout) | Array | Yes |
userEvent vs fireEvent
| Aspect | fireEvent | userEvent |
|---|---|---|
| Events fired | Single | Full sequence |
| Focus handling | No | Yes |
| Async | No | Yes |
| Realism | Low | High |
Async Utilities
| Utility | Purpose |
|---|---|
findBy* | Wait for element to appear |
waitFor | Wait for assertion to pass |
waitForElementToBeRemoved | Wait for element to disappear |
Best Practices
✅ Do This:
// Query by accessible role
screen.getByRole('button', { name: /submit/i })
// Use queryBy for absence
expect(screen.queryByText(/error/i)).not.toBeInTheDocument()
// Use findBy for async elements
expect(await screen.findByText(/loaded/i)).toBeInTheDocument()
// Use userEvent for interactions
const user = userEvent.setup()
await user.click(button)
❌ Don’t Do This:
// Query by CSS class
container.querySelector('.submit-btn') // implementation detail
// Use getBy for absence
expect(screen.getByText(/error/i)).not.toBeInTheDocument() // throws
// Use fireEvent for complex interactions
fireEvent.click(button) // misses focus, key events
// Forget to await userEvent
user.click(button) // assertion runs before handler
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| “Unable to find role” | Accessible name missing | Add aria-label or visible text |
| “Found multiple elements” | Query too broad | Add { name: ... } or use within |
| Assertion runs before async | No await on findBy or userEvent | Add await |
| Test fails after refactor | Querying implementation details | Query by role, label, or text |
act() warning | State update outside RTL wrapper | Use userEvent and findBy |
Real-World Examples
1. Render and query by role
render(<Button>Submit</Button>)
screen.getByRole('button', { name: /submit/i })
2. Query form field by label
screen.getByLabelText(/email/i)
3. Assert absence
expect(screen.queryByText(/error/i)).not.toBeInTheDocument()
4. Wait for async element
expect(await screen.findByText(/loaded/i)).toBeInTheDocument()
5. User click
const user = userEvent.setup()
await user.click(screen.getByRole('button'))
6. User type
await user.type(screen.getByLabelText(/name/i), 'Alice')
7. Wait for removal
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i))
8. Debug the DOM
screen.debug()
9. Within a section
const nav = screen.getByRole('navigation')
within(nav).getByRole('link', { name: /home/i })
10. Test ID escape hatch
screen.getByTestId('custom-widget')
Visual
Query Priority
┌──────────────────────────────────────────────┐
│ 1. getByRole │
│ accessible tree, screen readers │
│ │
│ 2. getByLabelText │
│ form fields │
│ │
│ 3. getByText │
│ non-interactive content │
│ │
│ 4. getByTestId │
│ last resort │
└──────────────────────────────────────────────┘
Query Variants
┌──────────────────────────────────────────────┐
│ getBy → throws if missing │
│ queryBy → null if missing │
│ findBy → retries until present │
│ │
│ Use getBy for "must be there now" │
│ Use queryBy for "must not be there" │
│ Use findBy for "will be there soon" │
└──────────────────────────────────────────────┘
userEvent vs fireEvent
┌──────────────────────────────────────────────┐
│ fireEvent.click(button) │
│ → dispatches one click event │
│ │
│ await user.click(button) │
│ → focus, mousedown, mouseup, click │
│ → checks visibility and interactability │
└──────────────────────────────────────────────┘
Async Testing
┌──────────────────────────────────────────────┐
│ findByText(/loaded/i) │
│ → retries every 50ms │
│ → resolves when found │
│ → throws after 1000ms │
│ │
│ No need for waitFor + getBy │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Library | @testing-library/react |
| Peer dependency | @testing-library/dom (RTL 16+) |
| Render function | render(<Component />) |
| Query object | screen |
| Top query | getByRole |
| Form query | getByLabelText |
| Absence query | queryBy |
| Async query | findBy |
| Interaction | userEvent (preferred) |
| Async wait | findBy or waitFor |
Key takeaways:
- RTL renders the full DOM and queries it like a user. The queries are accessible selectors, and the tests survive refactors that preserve the user-facing behavior .
- The query priority is a design constraint.
getByRolefirst,getByLabelTextfor form fields,getByTextfor content,getByTestIdonly when nothing else works. The priority pushes the component toward accessible markup . - The variant determines the failure mode.
getBythrows on missing,queryByreturnsnull,findByretries and returns a Promise. Use the correct variant for the assertion . findByis the async tool. It replaceswaitFor+getByfor the common case of an element that appears after a promise resolves .userEventis the realistic interaction. It fires the full sequence of events and checks visibility and interactability.fireEventfires a single event and is only for cases thatuserEventdoes not cover .userEvent.setup()is required. The setup call creates the user session, and the methods return Promises that must be awaited .
Remember: RTL is not a test runner; it is a set of queries and interactions that make component tests resemble the way users use the component. The queries are the priority—role, label, text, test ID—and the variant is the failure mode. The interaction is userEvent, and the async wait is findBy. The library’s philosophy is that the test should break when the user-facing behavior changes, not when the implementation changes. A test that queries by role and interacts with userEvent is a test that gives confidence; a test that queries by CSS class and fires raw events is a test that slows down every refactor.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!