Skip to content

React · Testing

React Testing Library — Complete Interview Guide

8 min read Updated 2026-06-24 Share:

Practice RTL Basics interview questions

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:

  1. No access to internals. RTL deliberately omits methods like instance(), state(), or find('.internal-class').
  2. Queries mirror what users see. Find elements by role, label, or text — the same signals a screen reader or a real user uses.
  3. 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:

VariantNot foundMultipleAsync
getBythrowsthrowsNo
queryBynullthrowsNo
findByrejectsrejectsYes — 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:

  1. getByRole — most inclusive; covers buttons, headings, links, inputs, dialogs, checkboxes, and more. Add { name: /text/i } to filter by accessible name.
  2. getByLabelText — for form fields with <label> elements.
  3. getByPlaceholderText — fallback when no visible label exists.
  4. getByText — paragraphs, list items, non-interactive text.
  5. getByDisplayValue — current value of a select or input.
  6. getByAltText — image alt text.
  7. getByTitletitle attribute.
  8. getByTestId — last resort; requires data-testid attribute 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 getByRole beats getByTestId.
  • Write async tests with findBy and waitFor.
  • Use userEvent.setup() and await every 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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel