MENU
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:
- Query the rendered output the way a user or assistive technology would -- by visible text, label, or ARIA role -- rather than by a component's internal state or a CSS class you invented for hooking into.
- Don't assert on a component's internal state variables or call its internal methods directly. If a behavior can't be observed by looking at the rendered DOM, it generally shouldn't be what your test asserts on.
- Avoid testing implementation details that could change during a harmless refactor (renaming a prop, restructuring which child renders a piece of text) without changing the actual user-facing behavior. Tests that break on those changes create false alarms and erode trust in the suite.
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:
- Non-async Server Components that don't fetch data can sometimes be rendered like a plain function component, but this is a narrow case and easy to get wrong as soon as the component changes.
- For anything that genuinely depends on the Server Components render pipeline -- data fetching in the component itself, cookies()/headers() access, streaming, Suspense boundaries resolved on the server -- reach for Playwright instead and test the rendered page in a real running app. That is a deliberate division of labor, not a workaround: component tests cover Client Component behavior, end-to-end tests cover the server rendering pipeline.
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. |
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.