NextjsComponent Testing

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:

TSX
// 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:

TSX
// 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
Warning
React Testing Library's render() function mounts components synchronously in jsdom, which does not support async Server Components the way the App Router renders them in a real request. You cannot render an async function component with `await getData()` at the top through render() and expect it to behave the same way it does on the server.

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

Tip
If you find yourself fighting to render a Server Component in a component test, that friction is a signal, not a blocker to work around — it usually means the logic worth testing should be pulled out into a plain function, and the remaining rendering should be verified with an end-to-end test instead.
Note
userEvent is generally preferred over the lower-level fireEvent for simulating clicks and typing, since it more closely mimics the sequence of real browser events (focus, pointer, keyboard) that an actual user interaction produces.