| |

React 84 ⚛️ Unit Testing Components with React Testing Library

⏱️ Estimated read: 13 min | |

React Testing Library (RTL) is not a test runner. It is a set of utilities that render a React component into a DOM and provide queries for finding elements the way a user would find them—by role, by label, by text—rather than by implementation details like component state or class names . The library’s guiding principle is explicit: “The more your tests resemble the way your software is used, the more confidence they can give you.” This principle is the reason RTL replaced Enzyme as the standard for React component testing. Enzyme’s shallow rendering and its ability to inspect internal state encouraged tests that broke when the component was refactored, even when the user-facing behavior was unchanged. RTL renders the full DOM and queries it through accessible selectors, so a refactor that preserves the output does not break the test .

The library has two layers. @testing-library/react provides the React-specific render function and the screen object. @testing-library/dom, which is a separate peer dependency since RTL 16, provides the query engine underneath . The queries are the heart of the library: getByRole, getByLabelText, getByText, and their queryBy and findBy variants. The variant determines the failure mode. getBy throws when no match is found. queryBy returns null and is used for asserting absence. findBy returns a Promise and retries until the element appears or the timeout expires, making it the correct tool for asynchronous rendering .

Key point: RTL renders the component into a real DOM and queries it through accessible selectors. Use getByRole first, getByLabelText for form fields, and getByTestId only as an escape hatch. Use getBy for elements that must be present now, queryBy for elements that must be absent, and findBy for elements that appear after async work. Prefer userEvent over fireEvent because it simulates the full interaction sequence rather than a single event.


Why RTL replaced Enzyme

The implementation-detail problem. Enzyme’s shallow renderer allowed tests to inspect a component’s props and state without rendering its children. This made tests fast, but it also made them brittle. A test that asserted wrapper.state('isOpen') === true broke when the state was moved to a context or a reducer, even though the dropdown still opened when the user clicked the button . RTL’s philosophy is that the user does not know or care about the state variable; the user cares that the dialog is visible. A test that asserts screen.getByRole('dialog') is present survives the refactor.

The accessibility problem. RTL’s query priority is designed to push developers toward accessible markup. getByRole queries the accessibility tree, which is what screen readers use. If a button has no accessible name, getByRole('button', { name: /submit/i }) fails, and the developer is forced to add an aria-label or visible text. The test failure becomes an accessibility improvement . The Enzyme test that found the button by its CSS class did not have this effect.

The async problem. Components fetch data. The initial render shows a loading state, and the data appears after a promise resolves. RTL’s findBy queries and waitFor utility handle this natively. The test awaits screen.findByText('Loaded'), and the query retries until the element appears or the default 1000ms timeout expires . Enzyme’s synchronous API required manual setTimeout and wrapper.update() calls, which were fragile and slow.


a. The render function and the screen object

The render function mounts the component into a container that is appended to document.body. It returns an object with the container, an unmount function, a rerender function, and the bound queries. The screen object is the recommended way to access those queries; it is bound to document.body and does not require destructuring .

// Import render and screen from RTL
import { render, screen } from '@testing-library/react'
// Import the component under test
import { Greeting } from './Greeting'

test('renders the greeting', () => {
  // Render the component into the DOM
  render(<Greeting name="Alice" />)

  // Query the DOM through screen
  // getByRole finds the heading by its accessible role and name
  const heading = screen.getByRole('heading', { name: /hello, alice/i })

  // Assert that the heading is in the document
  // toBeInTheDocument comes from @testing-library/jest-dom
  expect(heading).toBeInTheDocument()
})

The screen.debug() call is the debugging tool. It prints the current DOM, formatted, to the console. When a query fails, the error message includes the DOM, but screen.debug() is the explicit way to see what RTL “sees” after a render or an interaction .

The render result also has a rerender function for testing prop changes without unmounting the component. This is useful for components that should preserve internal state across prop updates .


b. The query priority and the variants

RTL defines a strict priority for queries, and the order is not arbitrary. The top priority is getByRole, because it queries the accessibility tree—the same tree that assistive technology uses. The second priority is getByLabelText for form fields, because that is how users find inputs. getByText is for non-interactive content. getByTestId is the last resort, because the user cannot see or hear a data-testid attribute .

// Priority 1: by role
screen.getByRole('button', { name: /submit/i })
screen.getByRole('textbox', { name: /email/i })

// Priority 2: by label for form fields
screen.getByLabelText(/password/i)

// Priority 3: by text for non-interactive content
screen.getByText(/welcome back/i)

// Priority 4: by alt text for images
screen.getByAltText(/company logo/i)

// Last resort: by test id
screen.getByTestId('custom-widget')

The variant determines the failure mode. getBy throws if no match or more than one match is found. queryBy returns null if no match is found and is the correct choice for asserting that an element is absent. findBy returns a Promise that resolves when the element appears, retrying until the timeout. getAllBy and findAllBy return arrays for multiple matches .

Variant0 matches1 match>1 matchesAsync
getBythrowsreturnsthrowsNo
queryBynullreturnsthrowsNo
findBythrows (after timeout)returnsthrowsYes

The findBy variant is the correct tool for async rendering. The test does not need waitFor and getBy; await screen.findByText('Loaded') is simpler and produces a better failure message when the element never appears .


c. userEvent, fireEvent, and the async interaction

fireEvent dispatches a single DOM event. fireEvent.click(button) fires a click event and nothing else. A real click involves focusing the element, pressing the mouse button, releasing it, and firing the associated events. fireEvent does not simulate that sequence. userEvent does. It is the companion library that “simulates user interactions by dispatching the events that would happen if the interaction took place in a browser” .

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

test('submits the form', async () => {
  // Set up the user session before rendering
  const user = userEvent.setup()

  render(<LoginForm />)

  // Type into the email field
  // user.type focuses the field, fires keydown, keypress, keyup, and input
  await user.type(screen.getByLabelText(/email/i), 'alice@example.com')

  // Click the submit button
  await user.click(screen.getByRole('button', { name: /submit/i }))

  // Assert that the success message appears
  expect(await screen.findByText(/welcome/i)).toBeInTheDocument()
})

The userEvent.setup() call is required since version 14. The user methods return Promises, so the test must be async and the calls must be awaited. A test that calls user.click() without await will fail because the assertion runs before the event handlers complete .

fireEvent is still useful for cases that userEvent does not cover—a specific event that the component listens for, or a low-level interaction that the user simulation does not model. But for the common interactions (click, type, select, keyboard), userEvent is the correct choice .


Complete Example Session

The following session builds a login form test with RTL, covering rendering, user interaction, async submission, and error handling.

// ============================================
// PART 1: THE COMPONENT UNDER TEST
// ============================================
import { useState } from 'react'

export function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  async function handleSubmit(e) {
    e.preventDefault()
    setError('')
    setLoading(true)
    try {
      await onSubmit({ email, password })
    } catch (err) {
      setError(err.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />

      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />

      {error && <div role="alert">{error}</div>}

      <button type="submit" disabled={loading}>
        {loading ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  )
}
// ============================================
// PART 2: THE TEST FILE
// ============================================
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'

test('submits the form with email and password', async () => {
  // Set up the user session
  const user = userEvent.setup()
  // A mock submit handler that resolves
  const onSubmit = jest.fn().mockResolvedValue()

  render(<LoginForm onSubmit={onSubmit} />)

  // Fill in the email field by its label
  await user.type(screen.getByLabelText(/email/i), 'alice@example.com')

  // Fill in the password field by its label
  await user.type(screen.getByLabelText(/password/i), 'secret123')

  // Click the submit button by its role and name
  await user.click(screen.getByRole('button', { name: /sign in/i }))

  // Assert that the handler was called with the expected values
  expect(onSubmit).toHaveBeenCalledWith({
    email: 'alice@example.com',
    password: 'secret123',
  })
})

test('shows an error when submission fails', async () => {
  const user = userEvent.setup()
  // A mock that rejects with an error
  const onSubmit = jest.fn().mockRejectedValue(new Error('Invalid credentials'))

  render(<LoginForm onSubmit={onSubmit} />)

  await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
  await user.type(screen.getByLabelText(/password/i), 'wrong')
  await user.click(screen.getByRole('button', { name: /sign in/i }))

  // findByRole waits for the alert to appear
  expect(await screen.findByRole('alert')).toHaveTextContent('Invalid credentials')
})

test('disables the button while submitting', async () => {
  const user = userEvent.setup()
  // A promise that never resolves, to keep the form in loading state
  const onSubmit = jest.fn(() => new Promise(() => {}))

  render(<LoginForm onSubmit={onSubmit} />)

  await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
  await user.type(screen.getByLabelText(/password/i), 'secret')
  await user.click(screen.getByRole('button', { name: /sign in/i }))

  // The button text changes to "Signing in..." and is disabled
  expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled()
})

The three parts cover the component, the success test, and the error and loading tests. What the session demonstrates is that the queries are accessible—getByLabelText for the inputs, getByRole for the button and the alert—and the interaction is simulated with userEvent, which is the realistic replacement for fireEvent.


Quick Reference

Query Priority

PriorityQueryUse For
1getByRoleButtons, headings, links, form elements
2getByLabelTextForm inputs
3getByPlaceholderTextWhen no label exists
4getByTextNon-interactive content
5getByDisplayValueFilled form fields
6getByAltTextImages
7getByTitleTitle attribute
8getByTestIdLast resort

Query Variants

VariantNo MatchMatchAsync
getByThrowsReturnsNo
queryBynullReturnsNo
findByThrows (timeout)ReturnsYes
getAllByThrowsArrayNo
queryAllBy[]ArrayNo
findAllByThrows (timeout)ArrayYes

userEvent vs fireEvent

AspectfireEventuserEvent
Events firedSingleFull sequence
Focus handlingNoYes
AsyncNoYes
RealismLowHigh

Async Utilities

UtilityPurpose
findBy*Wait for element to appear
waitForWait for assertion to pass
waitForElementToBeRemovedWait for element to disappear

Best Practices

✅ Do This:

// Query by accessible role
screen.getByRole('button', { name: /submit/i })

// Use queryBy for absence
expect(screen.queryByText(/error/i)).not.toBeInTheDocument()

// Use findBy for async elements
expect(await screen.findByText(/loaded/i)).toBeInTheDocument()

// Use userEvent for interactions
const user = userEvent.setup()
await user.click(button)

❌ Don’t Do This:

// Query by CSS class
container.querySelector('.submit-btn') // implementation detail

// Use getBy for absence
expect(screen.getByText(/error/i)).not.toBeInTheDocument() // throws

// Use fireEvent for complex interactions
fireEvent.click(button) // misses focus, key events

// Forget to await userEvent
user.click(button) // assertion runs before handler

Common Pitfalls

PitfallWhy It HappensFix
“Unable to find role”Accessible name missingAdd aria-label or visible text
“Found multiple elements”Query too broadAdd { name: ... } or use within
Assertion runs before asyncNo await on findBy or userEventAdd await
Test fails after refactorQuerying implementation detailsQuery by role, label, or text
act() warningState update outside RTL wrapperUse userEvent and findBy

Real-World Examples

1. Render and query by role

render(<Button>Submit</Button>)
screen.getByRole('button', { name: /submit/i })

2. Query form field by label

screen.getByLabelText(/email/i)

3. Assert absence

expect(screen.queryByText(/error/i)).not.toBeInTheDocument()

4. Wait for async element

expect(await screen.findByText(/loaded/i)).toBeInTheDocument()

5. User click

const user = userEvent.setup()
await user.click(screen.getByRole('button'))

6. User type

await user.type(screen.getByLabelText(/name/i), 'Alice')

7. Wait for removal

await waitForElementToBeRemoved(() => screen.queryByText(/loading/i))

8. Debug the DOM

screen.debug()

9. Within a section

const nav = screen.getByRole('navigation')
within(nav).getByRole('link', { name: /home/i })

10. Test ID escape hatch

screen.getByTestId('custom-widget')

Visual

Query Priority

┌──────────────────────────────────────────────┐
│  1. getByRole                                │
│     accessible tree, screen readers          │
│                                              │
│  2. getByLabelText                           │
│     form fields                              │
│                                              │
│  3. getByText                                │
│     non-interactive content                  │
│                                              │
│  4. getByTestId                              │
│     last resort                              │
└──────────────────────────────────────────────┘

Query Variants

┌──────────────────────────────────────────────┐
│  getBy   → throws if missing                 │
│  queryBy → null if missing                   │
│  findBy  → retries until present             │
│                                              │
│  Use getBy for "must be there now"           │
│  Use queryBy for "must not be there"         │
│  Use findBy for "will be there soon"         │
└──────────────────────────────────────────────┘

userEvent vs fireEvent

┌──────────────────────────────────────────────┐
│  fireEvent.click(button)                     │
│  → dispatches one click event                │
│                                              │
│  await user.click(button)                    │
│  → focus, mousedown, mouseup, click          │
│  → checks visibility and interactability     │
└──────────────────────────────────────────────┘

Async Testing

┌──────────────────────────────────────────────┐
│  findByText(/loaded/i)                       │
│  → retries every 50ms                        │
│  → resolves when found                       │
│  → throws after 1000ms                       │
│                                              │
│  No need for waitFor + getBy                 │
└──────────────────────────────────────────────┘

Summary

ItemValue
Library@testing-library/react
Peer dependency@testing-library/dom (RTL 16+)
Render functionrender(<Component />)
Query objectscreen
Top querygetByRole
Form querygetByLabelText
Absence queryqueryBy
Async queryfindBy
InteractionuserEvent (preferred)
Async waitfindBy or waitFor

Key takeaways:

  • RTL renders the full DOM and queries it like a user. The queries are accessible selectors, and the tests survive refactors that preserve the user-facing behavior .
  • The query priority is a design constraint. getByRole first, getByLabelText for form fields, getByText for content, getByTestId only when nothing else works. The priority pushes the component toward accessible markup .
  • The variant determines the failure mode. getBy throws on missing, queryBy returns null, findBy retries and returns a Promise. Use the correct variant for the assertion .
  • findBy is the async tool. It replaces waitFor + getBy for the common case of an element that appears after a promise resolves .
  • userEvent is the realistic interaction. It fires the full sequence of events and checks visibility and interactability. fireEvent fires a single event and is only for cases that userEvent does not cover .
  • userEvent.setup() is required. The setup call creates the user session, and the methods return Promises that must be awaited .

Remember: RTL is not a test runner; it is a set of queries and interactions that make component tests resemble the way users use the component. The queries are the priority—role, label, text, test ID—and the variant is the failure mode. The interaction is userEvent, and the async wait is findBy. The library’s philosophy is that the test should break when the user-facing behavior changes, not when the implementation changes. A test that queries by role and interacts with userEvent is a test that gives confidence; a test that queries by CSS class and fires raw events is a test that slows down every refactor.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!