Testing React Server Components in Next.js

Published: (February 28, 2026 at 01:00 AM EST)
7 min read
Source: Dev.to

Source: Dev.to

Understanding React Server Components

Before writing tests, it helps to understand what distinguishes RSCs from client components:

  • No client‑side state or lifecycle methods. RSCs cannot use useState, useEffect, or any other hook that depends on the browser environment.
  • Direct server‑side data access. They can await database queries, file reads, or fetch calls at the top level — no useEffect‑based data fetching required.
  • Zero client‑side JavaScript output. The component’s logic stays on the server; only the resulting HTML is sent to the browser.

In Next.js’s app directory, a component is a server component unless it explicitly includes the 'use client' directive. This means the majority of your component tree — layouts, pages, and most data‑fetching components — will be RSCs.

Why Testing RSCs Is Different

Standard React testing tools like @testing-library/react work by mounting components in a jsdom environment that simulates the browser. RSCs don’t run in the browser, so jsdom isn’t the right environment. Trying to render an RSC with the standard render function will either fail outright or produce misleading results.

Specific challenges to plan for

  • Server context. RSCs may depend on server‑only APIs — cookies(), headers(), or direct database access — that don’t exist in a test environment without explicit mocking.
  • Async rendering. Unlike class components or hooks‑based components, RSCs are async functions that return JSX. This affects how you render and assert in tests.
  • Next.js‑specific APIs. Functions like next/navigation’s redirect() or notFound(), and the next/headers module, need to be mocked to avoid errors in tests.

Tools and Setup

Dependencies

npm install --save-dev jest @testing-library/react @testing-library/jest-dom msw

For TypeScript projects, also install ts-jest and @types/jest.

Jest Configuration

Use the node test environment, not jsdom, since RSCs run on the server:

// jest.config.js
module.exports = {
  testEnvironment: 'node',
  moduleNameMapper: {
    '^@/(.*)$': '/src/$1',
  },
  setupFilesAfterEnv: ['/jest.setup.js'],
};
// jest.setup.js
import '@testing-library/jest-dom';

Mocking Next.js Server APIs

Several Next.js modules need to be mocked to prevent test failures. Create a __mocks__ directory at the project root:

// __mocks__/next/headers.js
export const cookies = jest.fn(() => ({
  get: jest.fn(),
  set: jest.fn(),
}));

export const headers = jest.fn(() => new Headers());
// __mocks__/next/navigation.js
export const redirect = jest.fn();
export const notFound = jest.fn();
export const useRouter = jest.fn(() => ({
  push: jest.fn(),
  replace: jest.fn(),
}));

Writing Tests for RSCs

The Component Under Test

Here’s a straightforward RSC that fetches and displays a list of users:

// app/components/UserList.js
export default async function UserList() {
  const res = await fetch('https://jsonplaceholder.typicode.com/users');
  const users = await res.json();

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>- {user.name}</li>
      ))}
    </ul>
  );
}

Rendering RSCs in Tests

Because RSCs are async functions, you need to await them before passing the result to a renderer. Use renderToString from react-dom/server to produce the HTML output:

import React from 'react';
import { renderToString } from 'react-dom/server';
import UserList from '@/components/UserList';

// Mock the global fetch
global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () =>
      Promise.resolve([
        { id: 1, name: 'John Doe' },
        { id: 2, name: 'Jane Smith' },
      ]),
  })
);

describe('UserList', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('renders a list of users', async () => {
    const component = await UserList();
    const html = renderToString(component);

    expect(html).toContain('John Doe');
    expect(html).toContain('Jane Smith');
  });

  it('fetches data from the correct endpoint', async () => {
    await UserList();
    expect(fetch).toHaveBeenCalledWith(
      'https://jsonplaceholder.typicode.com/users'
    );
  });

  it('renders the correct number of items', async () => {
    const component = await UserList();
    const html = renderToString(component);
    const listItems = (html.match(/<li>/g) || []).length;
    expect(listItems).toBe(2);
  });
});

Calling the component as an async function gives you its resolved output before rendering, which is necessary because RSCs return a promise that resolves to JSX. Rendering the resolved JSX with renderToString mirrors how the component would be processed on the server in production.

Using MSW for Complex API Scenarios

For more realistic API mocking—multiple endpoints, error states, network delays—MSW is more maintainable than manually stubbing fetch:

// tests/mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('https://jsonplaceholder.typicode.com/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'John Doe' },
      { id: 2, name: 'Jane Smith' },
    ]);
  }),
];
// tests/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// jest.setup.js (updated)
import { server } from './tests/mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

With this setup, your tests interact with realistic mock responses, and you can override handlers per‑test to simulate errors or edge cases:

it('handles API errors gracefully', async () => {
  server.use(
    http.get('https://jsonplaceholder.typicode.com/users', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  await expect(UserList()).rejects.toThrow();
});

Testing Components That Use Next.js Server APIs

When a component reads from cookies or headers, use the mocks you created earlier:

// app/components/AuthenticatedGreeting.js
import { cookies } from 'next/headers';

export default async function AuthenticatedGreeting() {
  const cookieStore = cookies();
  const session = cookieStore.get('session-token');

  if (!session) return <p>Please log in.</p>;
  return <p>Welcome back!</p>;
}
// __tests__/AuthenticatedGreeting.test.js
import { cookies } from 'next/headers';
import AuthenticatedGreeting from '@/components/AuthenticatedGreeting';
import { renderToString } from 'react-dom/server';

jest.mock('next/headers');

describe('AuthenticatedGreeting', () => {
  it('shows login prompt when no session exists', async () => {
    cookies.mockReturnValue({ get: jest.fn(() => null) });

    const component = await AuthenticatedGreeting();
    const html = renderToString(component);

    expect(html).toContain('Please log in.');
  });

  it('shows welcome message when session exists', async () => {
    cookies.mockReturnValue({
      get: jest.fn(() => ({ value: 'valid-token' })),
    });

    const component = await AuthenticatedGreeting();
    const html = renderToString(component);

    expect(html).toContain('Welcome back!');
  });
});

Debugging Common Issues

IssueFix
window is not definedYour test environment is set to jsdom. Change jest.config.js to testEnvironment: 'node'.
Cannot read properties of undefined on Next.js importsMock the problematic module (next/headers, next/navigation, etc.) in a __mocks__ folder or with jest.mock() at the top of the test file.
Async component not rendering correctlyawait the component before passing it to renderToString:
const component = await MyComponent(props);
renderToString(component);
Fetch mock not being calledEnsure global.fetch is assigned before the component runs (e.g., in beforeEach or at the top of the describe block).
Tests pass locally but fail in CIVerify that any required environment variables are available in CI, or mock them explicitly in the test setup.

What to Test (and What to Skip)

Not everything in an RSC needs a dedicated unit test. A practical breakdown:

  • Worth testing

    • Data‑transformation logic.
    • Conditional rendering based on fetched data or server state.
    • Error and loading states.
    • Correct API calls being made.
  • Better handled by integration or E2E tests

    • The full rendering pipeline.
    • Layout composition and routing behavior.
    • Anything that requires a real Next.js server.

Tools like Playwright or Cypress are better suited for end‑to‑end scenarios. Keep your Jest unit tests focused on component logic in isolation.

Conclusion

Testing React Server Components isn’t dramatically harder than testing client components—it just requires different tools and a slightly different mental model. The key shifts are:

  1. Use a node test environment instead of jsdom.
  2. Call async components directly (await MyComponent()) rather than using render().
  3. Mock Next.js server APIs explicitly.

As the ecosystem matures, expect this process to become smoother. The Next.js team and the React core team are actively working on better testing primitives for RSCs. For now, the patterns above give you a reliable foundation that covers the most common scenarios without requiring complex infrastructure.

Further reading

0 views
Back to Blog

Related posts

Read more »

The 'skill-check' JS quiz

Question 1: Type coercion What does the following code output to the console? javascript console.log0 == '0'; console.log0 === '0'; Answer: true, then false Ex...

The Xkcd thing, now interactive

Article URL: https://editor.p5js.org/isohedral/full/vJa5RiZWs Comments URL: https://news.ycombinator.com/item?id=47230704 Points: 21 Comments: 5...

The Last Dance with the past🕺

Introduction Hello dev.to community! A week ago I posted my first article introducing myself and explaining that I left web development to focus on cryptograph...