React Testing Library (RTL) is a lightweight testing utility built on
top of @testing-library/dom. Its core philosophy comes from a single
guiding principle:
"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds
This means RTL deliberately does not expose component internals (state, instance methods, refs). Instead, every query and assertion goes through the same DOM the user sees.
// ❌ Enzyme-style (tests implementation details)
wrapper.state('isOpen') // couples test to internal state name
wrapper.instance().handleClick() // calls method directly, bypassing UI
// ✅ RTL-style (tests user-visible behavior)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /open/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
The practical consequences:
- Queries find elements by role, label, text, or test-id — things real users and assistive technology rely on.
- Interactions go through
userEventorfireEvent, which dispatch real DOM events rather than calling handlers directly. - If you can't test it without accessing internals, the API is probably missing something worth exposing.
Rule of thumb: If your test would still pass after a complete internal refactor (renaming state, changing the component hierarchy), it's a good RTL test.
render() mounts a React component into a detached <div> that is
appended to document.body, runs all effects and state updates, then
returns a bag of query helpers bound to that container.
import { render, screen } from '@testing-library/react'
const { getByText, queryByRole, findByLabelText, container, unmount, rerender } =
render(<LoginForm />)
// Most common keys:
// container — the raw DOM node wrapping the rendered output
// unmount() — tear down the component (called automatically after each test)
// rerender() — re-render with new props
// All getBy*/queryBy*/findBy* helpers (scoped to this render)
In practice the bound helpers from the return value are rarely used
directly. The screen object exports the same queries but always
operates on document.body, which works even if multiple renders exist
in one test.
render(<LoginForm />)
// Prefer screen.* over the returned object
const button = screen.getByRole('button', { name: /sign in/i })
After each test, @testing-library/react calls cleanup() automatically
(via an afterEach it registers), which unmounts the component and removes
the container from document.body.
Rule of thumb: Always use screen.* for queries and keep the return
value only when you need rerender, unmount, or container.
RTL provides three prefixes for every query, each with different behavior on missing elements and async awareness.
| Variant | Not found | Multiple found | Async? |
|---|---|---|---|
getBy |
throws | throws | No |
queryBy |
returns null |
throws | No |
findBy |
rejects (Promise) | rejects | Yes (retries until timeout) |
// getBy — use when element MUST exist right now
const heading = screen.getByRole('heading', { name: /dashboard/i })
// queryBy — use to assert element is ABSENT (toBeNull / not.toBeInTheDocument)
expect(screen.queryByText('Error')).not.toBeInTheDocument()
// findBy — use when element appears AFTER an async operation
const item = await screen.findByText('Data loaded') // polls until found or timeout
All three have All variants (getAllBy, queryAllBy, findAllBy) that
return arrays and only throw when no elements are found (for getAllBy
and findAllBy).
Common mistakes:
- Using
getByto assert absence — it throws before you can check. - Forgetting
awaitonfindBy— returns a Promise, not the element. - Using
findBywhen the element is already there — wastes the polling overhead.
Rule of thumb: Get → exists now. Query → might not exist. Find → will exist soon.
screen is a re-export from @testing-library/dom that exposes the same
full query set but always targets document.body rather than a scoped
container.
import { render, screen } from '@testing-library/react'
render(<App />)
// Both work, but screen.* is preferred
const { getByRole } = render(<App />) // ❌ scoped to this render's container
screen.getByRole('navigation') // ✅ searches all of document.body
Advantages of screen:
- No destructuring — one import, always available.
- Works across renders — if a component portals into
document.bodyoutside the container,screenstill finds it. screen.debug()— prints the current DOM to the console for quick inspection without needing the return value.
screen.debug() // prints full document.body
screen.debug(screen.getByRole('dialog')) // prints just the dialog node
Rule of thumb: Always import and use screen; only keep the
render() return value when you need rerender, unmount, or the raw
container node.
RTL's official priority list (from the docs) guides you toward queries that reflect how real users and accessibility tools discover elements:
- By role (
getByRole) — matches ARIA roles; works for buttons, headings, links, inputs, dialogs, etc. Preferred for almost everything. - By label (
getByLabelText) — finds the form control associated with a<label>. Best for inputs, selects, textareas. - By placeholder (
getByPlaceholderText) — fallback when there is no visible label. - By text (
getByText) — finds nodes by their text content. Good for paragraphs, list items, non-interactive elements. - By display value (
getByDisplayValue) — current value of an input/select/textarea. - By alt text (
getByAltText) — for images. - By title (
getByTitle) —titleattribute. - By test id (
getByTestId) — only when nothing above is practical; requires addingdata-testidattributes to the DOM.
// ✅ Tier 1 — most accessible, most like how a user would find it
screen.getByRole('button', { name: /submit/i })
screen.getByRole('textbox', { name: /email/i })
// ✅ Tier 2 — great for forms with proper labels
screen.getByLabelText(/password/i)
// ⚠️ Last resort — opaque, requires markup change
screen.getByTestId('submit-btn')
The deeper reason: queries that would break if you changed a class name or state field are brittle. Queries that break only when the visible UI changes are meaningful.
Rule of thumb: Reach for getByRole first; add aria-label or
aria-labelledby to make otherwise unlabelled elements queryable before
falling back to data-testid.
Both simulate user interactions, but at very different levels of fidelity.
fireEvent dispatches a single synthetic DOM event and returns
immediately. It is synchronous and low-level.
userEvent (from @testing-library/user-event) simulates the full
sequence of events a real user triggers: pointer down, pointer up, focus,
keydown, input, change, click, blur, etc. Since v14 it is async.
import userEvent from '@testing-library/user-event'
import { fireEvent, render, screen } from '@testing-library/react'
render(<SearchBox />)
// fireEvent — fires one 'change' event, skips pointer/keyboard events
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'react' } })
// userEvent — types character by character, fires keydown/keypress/input/keyup per char
const user = userEvent.setup()
await user.type(screen.getByRole('textbox'), 'react')
When to use each:
- userEvent for anything the user actually does: typing, clicking, selecting, hovering, tabbing. It is the right default.
- fireEvent when you need to simulate an event that userEvent does not support, or in unit tests where you want a specific event without the full browser sequence.
Setup pattern (v14+):
// Call setup once per test, not inside loops
const user = userEvent.setup()
await user.click(button)
await user.keyboard('{Enter}')
Rule of thumb: Default to userEvent; use fireEvent only when
userEvent doesn't cover the event you need.
waitFor repeatedly executes a callback until it stops throwing (or the
timeout expires). It is used when an assertion depends on something that
hasn't happened yet — a state update, an effect, or an async API call.
import { render, screen, waitFor } from '@testing-library/react'
test('shows success message after save', async () => {
render(<SaveButton />)
await userEvent.setup().click(screen.getByRole('button', { name: /save/i }))
// The success message appears after a state update triggered by an API call
await waitFor(() => {
expect(screen.getByText('Saved!')).toBeInTheDocument()
})
})
Key options:
await waitFor(callback, {
timeout: 3000, // default 1000 ms
interval: 50, // polling interval, default 50 ms
})
waitFor vs findBy*:
findBy*is syntactic sugar forwaitFor(() => getBy*(...))— use it when you're waiting for a single element to appear.waitForis for more complex assertions (multiple elements, negative assertions, or checking a value, not just presence).
Common mistake — putting multiple assertions in one waitFor:
// ❌ If the first passes then the second fails, waitFor retries the whole block
await waitFor(() => {
expect(a).toBeInTheDocument()
expect(b).toBeInTheDocument() // a keeps re-asserting, masking failures
})
// ✅ Wait for the first, then assert synchronously
await screen.findByText('A')
expect(screen.getByText('B')).toBeInTheDocument()
Rule of thumb: Use findBy* for single async element appearances;
use waitFor for complex multi-assertion async scenarios.
@testing-library/jest-dom extends Jest/Vitest's expect with custom
DOM matchers that produce clearer error messages than raw Jest assertions.
Setup (once in your test setup file):
// vitest.config.ts / jest.setup.ts
import '@testing-library/jest-dom'
// or
import '@testing-library/jest-dom/vitest'
Most useful matchers:
// Presence
expect(el).toBeInTheDocument()
expect(el).not.toBeInTheDocument()
// Visibility
expect(el).toBeVisible() // not hidden via CSS
expect(el).toBeEnabled() // not disabled
expect(el).toBeDisabled()
// Content
expect(el).toHaveTextContent('Hello')
expect(el).toHaveTextContent(/hello/i)
// Form state
expect(checkbox).toBeChecked()
expect(input).toHaveValue('react')
expect(select).toHaveDisplayValue('Option A')
// Attributes & classes
expect(el).toHaveAttribute('href', '/home')
expect(el).toHaveClass('active')
// Focus
expect(input).toHaveFocus()
Without jest-dom you'd write expect(el).not.toBeNull() instead of
expect(el).toBeInTheDocument() — the latter produces a far more
descriptive failure message.
Rule of thumb: Always import @testing-library/jest-dom in your
global setup file so every test file gets the matchers automatically.
within() scopes all queries to a specific DOM node, letting you select
elements inside a particular region when multiple similar elements exist
on the page.
import { render, screen, within } from '@testing-library/react'
render(
<ul>
<li>
<span>Alice</span>
<button>Delete</button>
</li>
<li>
<span>Bob</span>
<button>Delete</button>
</li>
</ul>
)
// Without within — ambiguous: two "Delete" buttons exist
// screen.getByRole('button', { name: /delete/i }) — throws (multiple found)
// With within — unambiguous
const aliceRow = screen.getByText('Alice').closest('li')
within(aliceRow).getByRole('button', { name: /delete/i }).click()
Common use cases:
- Tables with per-row actions
- Lists where each item has the same repeated controls
- Modals/dialogs — scope queries to just the dialog content
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByRole('heading')).toHaveTextContent('Confirm')
await userEvent.setup().click(within(dialog).getByRole('button', { name: /cancel/i }))
Rule of thumb: When getBy* throws because multiple matching elements
exist, use within() to narrow the search to the relevant container.
The standard pattern: render the component, assert the loading state, then await the resolved state.
import { render, screen } from '@testing-library/react'
import { rest } from 'msw' // Mock Service Worker (preferred)
import { setupServer } from 'msw/node'
const server = setupServer(
rest.get('/api/users', (req, res, ctx) =>
res(ctx.json([{ id: 1, name: 'Alice' }]))
)
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('shows users after fetch', async () => {
render(<UserList />)
// Assert loading state first (optional but good practice)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// Wait for data to appear
expect(await screen.findByText('Alice')).toBeInTheDocument()
// Assert loading indicator is gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
test('shows error on fetch failure', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => res(ctx.status(500)))
)
render(<UserList />)
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument()
})
Without MSW you can mock fetch directly:
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 1, name: 'Alice' }],
})
Rule of thumb: Prefer MSW for HTTP mocking — it intercepts at the network level, so the same handlers work in both tests and the browser during development.
Four steps:
1. Install dependencies
npm install -D vitest @vitest/ui jsdom \
@testing-library/react @testing-library/user-event \
@testing-library/jest-dom
2. Configure Vitest (vitest.config.ts)
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // simulate the browser DOM
setupFiles: ['./src/test/setup.ts'],
},
})
3. Create setup file (src/test/setup.ts)
import '@testing-library/jest-dom/vitest' // custom matchers
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => cleanup()) // auto-cleanup (RTL registers this automatically, but explicit is safer)
4. Write a test
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect } from 'vitest'
import Counter from './Counter'
describe('Counter', () => {
it('increments on click', async () => {
render(<Counter />)
await userEvent.setup().click(screen.getByRole('button', { name: /increment/i }))
expect(screen.getByText('1')).toBeInTheDocument()
})
})
Rule of thumb: Set environment: 'jsdom' and import jest-dom in
your setup file — those two steps cover 90% of RTL configuration.
Use the rerender function returned by render(). It re-renders the same
component with new props without unmounting/remounting.
import { render, screen } from '@testing-library/react'
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>
}
test('updates when name prop changes', () => {
const { rerender } = render(<Greeting name="Alice" />)
expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice!')
rerender(<Greeting name="Bob" />)
expect(screen.getByRole('heading')).toHaveTextContent('Hello, Bob!')
})
rerender is also useful for testing prop-driven animations or conditional
rendering toggled from outside:
test('hides content when visible prop is false', () => {
const { rerender } = render(<Drawer visible={true}><p>Content</p></Drawer>)
expect(screen.getByText('Content')).toBeVisible()
rerender(<Drawer visible={false}><p>Content</p></Drawer>)
expect(screen.queryByText('Content')).not.toBeInTheDocument()
})
Rule of thumb: Use rerender to test prop changes; use userEvent
to test changes driven by user interaction.
Pass a wrapper option to render() that wraps the component under test
with any necessary providers.
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { ThemeProvider } from './ThemeContext'
// Inline wrapper
render(<NavBar />, {
wrapper: ({ children }) => (
<MemoryRouter initialEntries={['/dashboard']}>
<ThemeProvider theme="dark">
{children}
</ThemeProvider>
</MemoryRouter>
),
})
For reuse across many tests, create a custom render helper:
// test/utils.tsx
import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
function AllProviders({ children }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return (
<QueryClientProvider client={client}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>
)
}
const customRender = (ui, options) =>
render(ui, { wrapper: AllProviders, ...options })
export * from '@testing-library/react' // re-export everything
export { customRender as render } // override render
Then import from test/utils instead of @testing-library/react in
every test file.
Rule of thumb: Create a single customRender helper with all app-wide
providers so tests don't repeat provider boilerplate.
RTL offers several built-in debugging tools:
screen.debug() — prints the current DOM state to the console:
render(<ComplexForm />)
screen.debug() // prints all of document.body
screen.debug(screen.getByRole('form')) // prints just that subtree
screen.logTestingPlaygroundURL() — generates a URL to the
Testing Playground website pre-loaded with the current DOM, so you can
interactively explore which query to use:
screen.logTestingPlaygroundURL()
// Logs: https://testing-playground.com/#markup=...
prettyDOM() — programmatic version of debug, returns a string:
import { prettyDOM } from '@testing-library/react'
console.log(prettyDOM(document.body))
console.log(prettyDOM(someElement, 5000)) // second arg: max length
Query suggestion in error messages — when a query fails, RTL v13+ suggests alternative queries that would have matched:
TestingLibraryElementError: Unable to find an accessible element with the
role "button" and name "Submit"
Here are the accessible roles:
button:
Name "submit":
<button>submit</button> ← hint: case-sensitive name option used
Rule of thumb: When a test fails with "Unable to find element," call
screen.debug() immediately to see the actual DOM at that point.
Yes. Custom queries follow the same getBy/queryBy/findBy contract
and are useful when none of the built-in queries fit your domain — for
example, querying by a data attribute specific to your component library.
import { buildQueries, queryHelpers } from '@testing-library/react'
// Step 1 — the raw queryAll function
const queryAllByDataCy = (container, id) =>
queryHelpers.queryAllByAttribute('data-cy', container, id)
// Step 2 — build the full query family from queryAll
const [
queryByDataCy,
getAllByDataCy,
getByDataCy,
findAllByDataCy,
findByDataCy,
] = buildQueries(
queryAllByDataCy,
(c, id) => `Found multiple elements with data-cy="${id}"`,
(c, id) => `Unable to find element with data-cy="${id}"`
)
export { queryByDataCy, getAllByDataCy, getByDataCy, findAllByDataCy, findByDataCy }
Wire them into a custom render:
const customRender = (ui, options) =>
render(ui, {
queries: { ...queries, getByDataCy, findByDataCy },
...options,
})
In practice, the need for custom queries is rare. Common triggers:
- Your component library uses a non-standard attribute as the accessible identifier and you can't change it.
- You need to query by a complex compound condition.
Rule of thumb: Exhaust all built-in queries (especially getByRole
with name and within) before writing custom ones — they're extra
maintenance overhead.
renderHook is for testing custom hooks in isolation — when the hook
logic is complex enough to warrant direct tests but you don't want to
create a throwaway wrapper component manually.
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
test('increments the counter', () => {
const { result } = renderHook(() => useCounter(0))
// result.current holds the hook's return value
expect(result.current.count).toBe(0)
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
It also accepts a wrapper for context:
const { result } = renderHook(() => useAuth(), {
wrapper: ({ children }) => <AuthProvider>{children}</AuthProvider>,
})
When not to use renderHook:
- When the hook is trivial and already covered by component tests.
- When you'd rather test the hook through the component that uses it (integration test) — usually the better approach.
Rule of thumb: Use renderHook when a custom hook encapsulates
meaningful logic (state machines, async flows, caching) that deserves
direct tests beyond what component-level tests catch.
More Testing interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.