React Testing Library

React Testing Library (RTL) is the standard library for rendering React components in a test and interacting with the resulting output. It doesn't run tests by itself -- it needs a runner underneath it, almost always Jest or Vitest -- but it defines how you write the test itself: how you render a component, how you find elements in the output, and how you simulate a user interacting with them.

Test Behavior, Not Implementation

RTL is built around a specific philosophy, summarized by its own guiding principle: "the more your tests resemble the way your software is used, the more confidence they can give you." In practice this means:



Server Components vs. Client Components

This is the most important practical limitation to understand before writing RTL tests in a Next.js project.

render() in RTL renders a React element tree synchronously into a jsdom document. This works well for ordinary Client Components -- anything marked 'use client' -- because they render the same way in a test as they do in the browser.

Async Server Components (components that are async function and may await a data fetch) are a different story. They aren't invoked the way a normal component is; they are resolved through the React Server Components pipeline running inside the actual Next.js server, which jsdom and RTL have no access to. There is no supported way to render() an async Server Component directly in a Jest/Vitest + RTL test.

In practice this means:

Setting up React Testing Library alongside Jest (see Jest for the full jest.config.js). The key packages are @testing-library/react for rendering and querying, and @testing-library/user-event for simulating realistic user interactions.

jest.setup.js:
import '@testing-library/jest-dom'


Querying by Role and Text

RTL's screen object exposes queries grouped by how forgiving they are about matching. The library's own recommendation is to prefer, in order: getByRole (matches the accessibility tree -- how assistive tech sees the page), then getByLabelText / getByPlaceholderText / getByText, and to fall back to getByTestId only when nothing else reasonably applies.

getByRole('button', { name: /submit/i }) Finds an element by its ARIA role and accessible name -- the closest match to how a screen reader user finds it.
getByLabelText('Email') Finds a form field by its associated <label>, the way a sighted user reads a form.
getByText('Welcome back') Finds an element by its visible text content.
getByTestId('cart-total') Falls back to a data-testid attribute when there's no accessible way to target the element -- use sparingly.
queryBy... vs getBy... queryBy returns null if nothing matches (useful for asserting something is absent); getBy throws, which is usually what you want when the element should exist.
findBy... Async version that retries until the element appears or a timeout elapses -- for elements that show up after a state update or a resolved promise.
A Client Component with local state, and a test exercising it purely through user-visible behavior: querying by role/label, firing events with user-event, and asserting on rendered text -- never touching the component's internal state directly.

components,NewsletterForm.tsx:
'use client'

import { useState } from 'react'

export default function NewsletterForm() {
  const [email, setEmail] = useState('')
  const [submitted, setSubmitted] = useState(false)

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!email.includes('@')) return
    setSubmitted(true)
  }

  if (submitted) {
    return <p role="status">Thanks for subscribing, {email}!</p>
  }

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

components,NewsletterForm.test.tsx:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import NewsletterForm from './NewsletterForm'

describe('NewsletterForm', () => {
  it('shows a thank-you message after a valid submission', async () => {
    const user = userEvent.setup()
    render(<NewsletterForm />)

    await user.type(screen.getByLabelText('Email'), 'ada@example.com')
    await user.click(screen.getByRole('button', { name: /subscribe/i }))

    expect(
      screen.getByRole('status')
    ).toHaveTextContent('Thanks for subscribing, ada@example.com!')
  })

  it('does not submit with an invalid email', async () => {
    const user = userEvent.setup()
    render(<NewsletterForm />)

    await user.type(screen.getByLabelText('Email'), 'not-an-email')
    await user.click(screen.getByRole('button', { name: /subscribe/i }))

    // the form is still on screen -- no thank-you message appeared
    expect(screen.getByRole('button', { name: /subscribe/i })).toBeInTheDocument()
  })
})


Firing Events

@testing-library/user-event is preferred over RTL's lower-level fireEvent for anything involving typing, clicking, or tabbing, because it simulates the full sequence of real browser events (pointerdown, focus, keydown, input, keyup...) rather than dispatching a single synthetic event. This catches bugs that a single fireEvent.change() call would miss -- for example, a component that only updates on a real keystroke sequence.

Every user-event interaction is asynchronous, so calls like user.click() and user.type() must be awaited.



Pairing with a Runner

React Testing Library itself is runner-agnostic. See Jest for the more established setup, or Vitest for a faster alternative -- the RTL code in your test files is identical either way; only the config and a couple of import paths differ.