All topics
Testing · Learning hub

React Testing Library notes for developers

Master React Testing Library with a curated set of 3 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallMore Testing notes
React Testing Library

Render, Screen & Queries

React Testing Library: Render, Screen & Queries RTL encourages testing components the way users interact with them — through accessibility roles, labels, and vi

React Testing Library: Render, Screen & Queries

RTL encourages testing components the way users interact with them — through accessibility roles, labels, and visible text — rather than testing implementation details like component state or internal methods.

Setup & render

// Install
// npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom
// npm install --save-dev vitest jsdom @vitejs/plugin-react  (Vite projects)

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
  plugins: [react()],
  test: { environment: 'jsdom', globals: true, setupFiles: './src/test/setup.ts' },
})

// src/test/setup.ts
import '@testing-library/jest-dom'

// Basic render
import { render, screen } from '@testing-library/react'
import { Button } from './Button'

test('renders button with label', () => {
  render(<Button onClick={jest.fn()}>Save</Button>)
  expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})

Query Types & Priority

RTL has three query variants × multiple query strategies. Always prefer the most accessible query (role > label > text > testId).

Query variants:
  getBy*     — throws if not found or multiple found. Use for asserting element exists.
  queryBy*   — returns null if not found. Use to assert element does NOT exist.
  findBy*    — async, returns Promise. Use when element appears after async operation.
  getAllBy*  / queryAllBy* / findAllBy* — same but for multiple elements.

Query strategies (priority order — use highest available):
  ByRole          getByRole('button', { name: /save/i })     — most preferred (accessibility)
  ByLabelText     getByLabelText('Email address')            — form inputs
  ByPlaceholderText getByPlaceholderText('Search...')
  ByText          getByText(/hello world/i)                  — non-interactive elements
  ByDisplayValue  getByDisplayValue('Alice')                 — selected input value
  ByAltText       getByAltText('Profile photo')              — images
  ByTitle         getByTitle('Close')
  ByTestId        getByTestId('submit-btn')                  — last resort
// ByRole — most powerful query
// ARIA roles: button, link, heading, textbox, checkbox, radio,
//             combobox, listbox, option, img, dialog, alert, navigation, main

screen.getByRole('heading', { level: 1 })           // <h1>
screen.getByRole('textbox', { name: /email/i })     // <input> with label "Email"
screen.getByRole('button', { name: /submit/i })
screen.getByRole('checkbox', { name: /agree/i, checked: true })
screen.getByRole('combobox', { name: /country/i })  // <select>

// ByLabelText — recommended for form inputs
screen.getByLabelText('Email address')
screen.getByLabelText(/password/i)
// Works with: <label for>, aria-label, aria-labelledby, title

// ByText — for static content
screen.getByText('Hello, World!')
screen.getByText(/hello/i)                          // regex — case insensitive
screen.getByText('Price:', { exact: false })        // substring match

// queryBy — assert absence
expect(screen.queryByText('Error message')).not.toBeInTheDocument()

// getAllBy — multiple elements
const items = screen.getAllByRole('listitem')
expect(items).toHaveLength(3)

jest-dom Matchers

// @testing-library/jest-dom extends expect() with DOM-specific matchers
expect(element).toBeInTheDocument()
expect(element).not.toBeInTheDocument()
expect(element).toBeVisible()
expect(element).toBeDisabled()
expect(element).toBeEnabled()
expect(element).toBeChecked()
expect(element).toHaveFocus()

expect(element).toHaveValue('alice@example.com')
expect(element).toHaveDisplayValue('Option A')
expect(element).toHaveTextContent(/hello/i)
expect(element).toHaveTextContent('exact string')

expect(element).toHaveAttribute('aria-label', 'Close')
expect(element).toHaveAttribute('disabled')
expect(element).toHaveClass('btn-primary')
expect(element).toHaveStyle({ backgroundColor: 'red' })
React Testing Library

User Events & Async Testing

React Testing Library: User Events & Async userEvent (v14) Always prefer userEvent over fireEvent — it simulates real browser interactions including focus, keyb

React Testing Library: User Events & Async

userEvent (v14)

Always prefer userEvent over fireEvent — it simulates real browser interactions including focus, keyboard, pointer events, and input composition.

import userEvent from '@testing-library/user-event'

// Setup once per test (creates an instance with pointer/keyboard state)
const user = userEvent.setup()

test('user can fill and submit form', async () => {
  const onSubmit = jest.fn()
  render(<LoginForm onSubmit={onSubmit} />)

  // Typing — triggers input, change, keydown, keyup events
  await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
  await user.type(screen.getByLabelText(/password/i), 'secret123')

  // Click — triggers pointer events, focus, click
  await user.click(screen.getByRole('button', { name: /sign in/i }))

  expect(onSubmit).toHaveBeenCalledWith({
    email: 'alice@example.com',
    password: 'secret123',
  })
})

// Other userEvent methods
await user.clear(input)                            // clear input value
await user.selectOptions(select, 'option-value')   // <select>
await user.deselectOptions(multiSelect, 'val')
await user.upload(fileInput, new File([''], 'photo.jpg', { type: 'image/jpeg' }))
await user.keyboard('{Tab}')                       // press Tab
await user.keyboard('{Enter}')
await user.keyboard('[ShiftLeft>]A[/ShiftLeft]')   // shift+A
await user.hover(element)
await user.unhover(element)
await user.dblClick(element)

Async Testing

// findBy* — waits up to 1000ms (configurable) for element to appear
test('shows error after failed login', async () => {
  render(<LoginForm />)
  await user.click(screen.getByRole('button', { name: /sign in/i }))

  // findBy retries until element appears or timeout
  const error = await screen.findByRole('alert')
  expect(error).toHaveTextContent(/invalid credentials/i)
})

// waitFor — wait for an assertion to pass (more flexible)
import { waitFor } from '@testing-library/react'

test('shows success message after save', async () => {
  render(<SaveButton />)
  await user.click(screen.getByRole('button', { name: /save/i }))

  await waitFor(() => {
    expect(screen.getByText(/saved successfully/i)).toBeInTheDocument()
  })
  // or with timeout:
  await waitFor(
    () => expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(),
    { timeout: 3000 }
  )
})

// waitForElementToBeRemoved — explicitly wait for removal
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'))

// act() — wrap state updates that happen outside events
import { act } from '@testing-library/react'
await act(async () => {
  jest.advanceTimersByTime(1000)  // advance fake timers
})

Testing Hooks

import { renderHook, act } from '@testing-library/react'

// renderHook — test custom hooks in isolation
test('useCounter increments correctly', () => {
  const { result } = renderHook(() => useCounter(0))

  expect(result.current.count).toBe(0)

  act(() => result.current.increment())
  expect(result.current.count).toBe(1)

  act(() => result.current.decrement())
  expect(result.current.count).toBe(0)
})

// Hook with async state
test('useFetchUser fetches user data', async () => {
  const { result } = renderHook(() => useFetchUser('123'))

  expect(result.current.loading).toBe(true)

  await waitFor(() => expect(result.current.loading).toBe(false))

  expect(result.current.data?.name).toBe('Alice')
  expect(result.current.error).toBeNull()
})

// Hook that needs a wrapper (e.g., context)
test('useAuth requires AuthProvider', () => {
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <AuthProvider><>{children}</></AuthProvider>
  )
  const { result } = renderHook(() => useAuth(), { wrapper })
  expect(result.current.isAuthenticated).toBe(false)
})
React Testing Library

Providers, MSW & Best Practices

React Testing Library: Providers, MSW & Best Practices Custom render with Providers // src/test/test-utils.tsx — wrap render with all global providers import {

React Testing Library: Providers, MSW & Best Practices

Custom render with Providers

// src/test/test-utils.tsx — wrap render with all global providers
import { render, RenderOptions } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import { ThemeProvider } from './ThemeProvider'

function AllProviders({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },  // no retries in tests
  })
  return (
    <QueryClientProvider client={queryClient}>
      <MemoryRouter>
        <ThemeProvider theme="light">
          {children}
        </ThemeProvider>
      </MemoryRouter>
    </QueryClientProvider>
  )
}

const customRender = (ui: React.ReactElement, options?: RenderOptions) =>
  render(ui, { wrapper: AllProviders, ...options })

// Re-export everything from RTL but override render
export * from '@testing-library/react'
export { customRender as render }

// Usage — import from test-utils instead of @testing-library/react
import { render, screen } from '../test/test-utils'

MSW — Mock Service Worker

// npm install --save-dev msw
// MSW intercepts network requests at the service worker / Node.js level

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice', email: 'alice@example.com' },
    ])
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json({ id: 2, ...body }, { status: 201 })
  }),

  http.get('/api/users/:id', ({ params }) => {
    if (params.id === '999')
      return new HttpResponse(null, { status: 404 })
    return HttpResponse.json({ id: params.id, name: 'Bob' })
  }),
]

// src/mocks/server.ts (Node.js — for tests)
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)

// src/test/setup.ts
import { server } from '../mocks/server'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

// Override handler per test
test('shows error on server failure', async () => {
  server.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 })))
  render(<UserList />)
  expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument()
})

Best Practices

  • Query priority: ByRole > ByLabelText > ByText > ByTestId. If you need ByTestId, consider improving accessibility.

  • Avoid testing implementation details: don't test state, refs, or internal method calls. Test what the user sees and can interact with.

  • Use userEvent over fireEvent: userEvent simulates real browser behavior including all intermediate events.

  • Each test should be independent: don't share mutable state between tests. Use beforeEach to reset.

  • Use describe blocks to group related tests but avoid deeply nested describes — flat is easier to read.

  • Test accessibility: use ByRole queries and check that screen readers would work correctly.

  • Avoid act() warnings: they indicate uncaptured state updates. Use waitFor or findBy instead of manually wrapping in act.

  • Don't test every render: focus on behavior and interactions, not that a component renders X elements.

  • Snapshot tests: useful for catching unintended changes, but don't over-rely on them. Prefer explicit assertions.

// ❌ Testing implementation details
expect(wrapper.state().isLoading).toBe(false)
expect(component.instance().handleSubmit).toHaveBeenCalled()

// ✅ Testing user-visible behavior
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(onSubmit).toHaveBeenCalledWith({ email: 'alice@example.com' })

// ❌ Querying by test ID when a better option exists
screen.getByTestId('submit-button')

// ✅ Query by role (also validates accessibility)
screen.getByRole('button', { name: /submit/i })

// ❌ Not cleaning up between tests (can cause cross-test pollution)
let component: RenderResult
beforeAll(() => { component = render(<MyComponent />) })

// ✅ Fresh render per test — RTL auto-cleans after each test
test('does something', () => {
  render(<MyComponent />)
  // ...
})

Keep your React Testing Library knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever