Component Testing
Component tests sit between unit tests and full end-to-end tests: they render a single React component in isolation and interact with it the way a real user would, without spinning up a browser or a full running app. React Testing Library (RTL) is the standard tool for this in the React ecosystem, and it pairs naturally with either Jest or Vitest as the test runner.
The RTL philosophy
React Testing Library's guiding principle is: test your components the way a user actually experiences them, not the way they happen to be implemented internally. That means querying the rendered output by role, label, or visible text — the same things a real user (or a screen reader) would see — rather than reaching into component internals, state, or CSS class names.
Prefer getByRole('button', { name: /submit/i }) over reaching for a class name or test-id where a real accessible role exists.
Prefer getByLabelText('Email') for form fields over querying an input by its name attribute.
Reserve getByTestId as a last resort, for elements that genuinely have no accessible role or text.
If a test breaks because you renamed an internal variable or changed how state is stored, but the UI behaves identically, that's a sign the test was coupled to implementation details rather than user-facing behavior.
A worked example: a Client Component
Take a simple counter, built as a Client Component:
// components/Counter.tsx
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
)
}The test renders it, finds the button the way a user would (by its accessible role and label), clicks it, and asserts on the visible text that changed:
// components/Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
describe('Counter', () => {
it('starts at zero', () => {
render(<Counter />)
expect(screen.getByText('Count: 0')).toBeInTheDocument()
})
it('increments when the button is clicked', async () => {
const user = userEvent.setup()
render(<Counter />)
const button = screen.getByRole('button', { name: 'Increment' })
await user.click(button)
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
})Nothing in this test knows or cares that the component uses useState internally — it only asserts on what actually appears on screen after a real user interaction, which is exactly the point.
Server Components need a different approach
In practice, teams handle this in one of a few ways:
Extract the interesting logic into a plain, testable function (a data transform, a formatter) and unit test that directly, leaving the Server Component itself as a thin wrapper.
Test Server Components at a higher level with end-to-end tools (Playwright/Cypress) that actually run the Next.js server and exercise the real rendering path.
Keep the truly interactive, stateful pieces as small Client Components and cover those with React Testing Library, since that's the layer RTL is built for.
Where each approach fits
Component type | Good testing approach |
|---|---|
Client Component with interactivity/state | React Testing Library (render + user-event) |
Server Component with async data fetching | Extract logic to a plain function for unit testing, or cover via E2E |
Pure presentational component (no state) | React Testing Library, asserting on rendered output |