Why React Testing Library replaced Enzyme
For years, Enzyme was the go-to tool for React testing. It gave you direct access to component internals: state, instance methods, lifecycle hooks, shallow rendering that skipped child components entirely. Tests were fast and surgical.
They were also brittle. Rename a piece of internal state and six tests break. Extract a child component and shallow-rendered tests stop reaching the logic you care about. Move from class to function components and the entire API surface changes.
React Testing Library (RTL) made a different bet: test components the way
users use them. Users can't see state.isOpen. They can see a button, they can
click it, and they can see a menu appear. Tests that describe this behavior
survive refactors because they're coupled to the observable output — the DOM —
not the implementation.
This guide walks through every RTL concept that appears in interviews.
The core philosophy
Kent C. Dodds (RTL's creator) distilled the philosophy into one line:
"The more your tests resemble the way your software is used, the more confidence they can give you."
Three practical consequences:
- No access to internals. RTL deliberately omits methods like
instance(),state(), orfind('.internal-class'). - Queries mirror what users see. Find elements by role, label, or text — the same signals a screen reader or a real user uses.
- Interactions fire real events.
userEvent.click()triggers the full pointer/mouse/focus sequence, not just a synthetic click handler.
Setting up RTL with Vitest
npm install -D vitest @vitest/ui jsdom \
@testing-library/react @testing-library/user-event \
@testing-library/jest-dom
vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})
src/test/setup.ts
import '@testing-library/jest-dom/vitest'
That's the entire setup. Everything else is imported on a per-test basis.
render() and screen
render() mounts a component into a <div> attached to document.body and
returns a bag of query helpers. But in practice you almost never use those
return-value helpers directly — you use screen instead.
import { render, screen } from '@testing-library/react'
import Greeting from './Greeting'
test('renders a greeting', () => {
render(<Greeting name="Alice" />)
// screen queries target document.body — works for portals too
expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice')
})
screen has three advantages over the return-value helpers:
- No destructuring required — one import, always available.
- Finds elements anywhere in
document.body, including portals. screen.debug()dumps the current DOM to the console without needing the return value.
When you do need the return value, it's for rerender, unmount, or
container:
const { rerender, unmount, container } = render(<Counter value={0} />)
rerender(<Counter value={5} />)
The three query variants
Every RTL query comes in three flavors:
| Variant | Not found | Multiple | Async |
|---|---|---|---|
getBy | throws | throws | No |
queryBy | null | throws | No |
findBy | rejects | rejects | Yes — polls until timeout |
// getBy — element MUST be present right now
const btn = screen.getByRole('button', { name: /submit/i })
// queryBy — element MIGHT not exist (use with not.toBeInTheDocument)
expect(screen.queryByText('Error')).not.toBeInTheDocument()
// findBy — element will appear after async work (returns Promise)
const item = await screen.findByText('Data loaded')
All three have All variants that return arrays: getAllBy, queryAllBy,
findAllBy.
Query priority — which query to reach for first
RTL's official priority order, from most to least preferred:
getByRole— most inclusive; covers buttons, headings, links, inputs, dialogs, checkboxes, and more. Add{ name: /text/i }to filter by accessible name.getByLabelText— for form fields with<label>elements.getByPlaceholderText— fallback when no visible label exists.getByText— paragraphs, list items, non-interactive text.getByDisplayValue— current value of a select or input.getByAltText— image alt text.getByTitle—titleattribute.getByTestId— last resort; requiresdata-testidattribute changes.
The ranking reflects accessibility: queries at the top are the same signals screen readers use. Tests that pass at the top levels also verify that your markup is accessible.
// ✅ Best — tests accessibility too
screen.getByRole('button', { name: /sign in/i })
screen.getByLabelText(/email address/i)
// ⚠️ Last resort
screen.getByTestId('submit-btn')
userEvent vs fireEvent
Both simulate user interactions, but userEvent is almost always what you
want.
fireEvent dispatches a single synthetic DOM event synchronously:
fireEvent.click(button) // one click event, nothing else
fireEvent.change(input, { target: { value: 'hello' } }) // one change event
userEvent simulates the real browser sequence of events:
const user = userEvent.setup()
await user.click(button) // pointerover, pointerenter, mouseover, mouseenter,
// pointermove, mousemove, pointerdown, mousedown,
// focus, pointerup, mouseup, click
await user.type(input, 'hello') // one keydown+keypress+input+keyup cycle per char
Since v14, all userEvent methods are async — always await them.
Create the user object once per test:
const user = userEvent.setup()
await user.type(input, 'react')
await user.click(submitButton)
waitFor and async assertions
When state updates happen asynchronously (after a fetch, a timer, or a debounce), you need to wait for the DOM to reflect the new state.
findBy* is the cleanest solution for single-element waits:
const successMsg = await screen.findByText('Saved successfully')
waitFor is for more complex multi-assertion async scenarios:
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
Common mistake — putting multiple assertions in one waitFor:
// ❌ Problematic: if first passes but second fails, it retries the whole block
await waitFor(() => {
expect(screen.getByText('A')).toBeInTheDocument()
expect(screen.getByText('B')).toBeInTheDocument()
})
// ✅ Better: wait for the trigger, then assert synchronously
await screen.findByText('A')
expect(screen.getByText('B')).toBeInTheDocument()
@testing-library/jest-dom matchers
Without jest-dom you'd write:
expect(element).not.toBeNull()
expect(element.textContent).toBe('hello')
expect(element.disabled).toBe(true)
With jest-dom:
expect(element).toBeInTheDocument()
expect(element).toHaveTextContent('hello')
expect(element).toBeDisabled()
The jest-dom versions produce far better failure messages. Essential matchers:
// Presence / visibility
toBeInTheDocument()
toBeVisible()
toBeEnabled() / toBeDisabled()
// Content
toHaveTextContent('text')
toHaveTextContent(/regex/i)
// Form state
toBeChecked()
toHaveValue('input value')
toHaveDisplayValue('Option A')
// DOM properties
toHaveAttribute('href', '/home')
toHaveClass('active', 'selected')
toHaveFocus()
toHaveStyle({ display: 'flex' })
Debugging failing tests
When a test fails with "Unable to find element":
// Print the full DOM
screen.debug()
// Print a specific subtree
screen.debug(screen.getByRole('form'))
// Open Testing Playground with current DOM pre-loaded
screen.logTestingPlaygroundURL()
RTL v13+ also prints a role breakdown in error messages, showing you which elements matched and suggesting alternative queries.
Wrapping providers
Components that need router or context providers require a wrapper:
render(<NavBar />, {
wrapper: ({ children }) => (
<MemoryRouter>
<ThemeProvider theme="dark">{children}</ThemeProvider>
</MemoryRouter>
),
})
For larger projects, create a custom render helper so you don't repeat this:
// test/utils.tsx
import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
const customRender = (ui, options) =>
render(ui, { wrapper: ({ children }) => <MemoryRouter>{children}</MemoryRouter>, ...options })
export * from '@testing-library/react'
export { customRender as render }
within() for scoped queries
When a page has multiple similar elements (multiple "Delete" buttons, for
example), use within to scope queries to a specific container:
const aliceRow = screen.getByText('Alice').closest('tr')
within(aliceRow).getByRole('button', { name: /delete/i }).click()
Testing async data fetching
The full pattern with MSW:
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const server = setupServer(
http.get('/api/users', () => HttpResponse.json([{ id: 1, name: 'Alice' }]))
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('loads and displays users', async () => {
render(<UserList />)
expect(screen.getByRole('status')).toBeInTheDocument() // loading spinner
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
Interview checklist
Before your next React testing interview, make sure you can:
- Explain RTL's philosophy and how it differs from Enzyme.
- Choose the right query variant (get/query/find) for any scenario.
- Apply query priority and explain why
getByRolebeatsgetByTestId. - Write async tests with
findByandwaitFor. - Use
userEvent.setup()andawaitevery interaction. - Set up MSW handlers for happy path, 4xx, and 5xx.
- Scope queries with
within(). - Debug a failing test with
screen.debug().
Rule of thumb: If your test reads like a user story — "user clicks the button, sees the success message" — it's a good RTL test. If it reads like an implementation audit — "state.isSubmitting is true" — rewrite it.