Skip to content

RTL Basics Interview Questions & Answers

16 questions Updated 2026-06-24 Share:

React Testing Library interview questions — render, screen, queries, userEvent, waitFor, jest-dom matchers, async testing, and the RTL testing philosophy.

Read the in-depth guideReact Testing Library — Complete Interview Guide(opens in new tab)
16 of 16

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 userEvent or fireEvent, 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 getBy to assert absence — it throws before you can check.
  • Forgetting await on findBy — returns a Promise, not the element.
  • Using findBy when 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:

  1. No destructuring — one import, always available.
  2. Works across renders — if a component portals into document.body outside the container, screen still finds it.
  3. 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:

  1. By role (getByRole) — matches ARIA roles; works for buttons, headings, links, inputs, dialogs, etc. Preferred for almost everything.
  2. By label (getByLabelText) — finds the form control associated with a <label>. Best for inputs, selects, textareas.
  3. By placeholder (getByPlaceholderText) — fallback when there is no visible label.
  4. By text (getByText) — finds nodes by their text content. Good for paragraphs, list items, non-interactive elements.
  5. By display value (getByDisplayValue) — current value of an input/select/textarea.
  6. By alt text (getByAltText) — for images.
  7. By title (getByTitle) — title attribute.
  8. By test id (getByTestId) — only when nothing above is practical; requires adding data-testid attributes 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 for waitFor(() => getBy*(...)) — use it when you're waiting for a single element to appear.
  • waitFor is 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 ways to practice

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

or
Join our WhatsApp Channel