React Server Components (RSC) represent a meaningful shift in how React applications are structured. By moving rendering to the server, they eliminate unnecessary client-side JavaScript, enable direct data access without an API layer, and improve both performance and SEO. Next.js embraces this model fully — in the app directory, every component is a server component by default.
The tradeoff is that testing becomes more involved. The same server-side nature that makes RSCs powerful also makes them incompatible with the client-side testing tools most React developers reach for first. This article walks through what you need to know: the unique challenges RSCs pose for testing, how to configure your environment, and how to write tests that actually reflect how your components behave in production.
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
awaitdatabase queries, file reads, or fetch calls at the top level — nouseEffect-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.
The 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: {
'^@/(.*)$': '<rootDir>/src/$1',
},
setupFilesAfterFramework: ['<rootDir>/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);
});
});
Why
await UserList()instead ofrender(<UserList />)? Calling the component as an async function gives you its resolved output before rendering, which is necessary because React's current test renderer doesn't handle top-level async components. This pattern will become simpler as tooling catches up with the RSC spec.
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>;
}
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
window is not defined
Your test environment is set to jsdom instead of node. Update jest.config.js to use testEnvironment: 'node'.
Cannot read properties of undefined on Next.js imports
A Next.js module (most commonly next/headers or next/navigation) isn't mocked. Add it to your __mocks__ directory or use jest.mock() at the top of the test file.
Async component not rendering correctly
Make sure you're awaiting the component call before passing it to renderToString. The pattern is const component = await MyComponent(props) followed by renderToString(component).
Fetch mock not being called
Check that global.fetch is assigned before the component runs. Define it in beforeEach or at the top of the describe block rather than outside all tests.
Tests passing locally but failing in CI
Server-side tests can be sensitive to environment variables. Ensure any .env.local values your components depend on are also available in the CI environment, or mock them explicitly.
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, routing behavior, and anything that requires a real Next.js server to be running.
Tools like Playwright or Cypress are better suited for end-to-end scenarios. Keep your Jest unit tests focused on the component's logic in isolation.
Conclusion
Testing React Server Components isn't dramatically harder than testing client components — it just requires different tools and a different mental model. The key shifts are using a node test environment instead of jsdom, calling async components directly rather than using render(), and mocking Next.js's server APIs explicitly.
As the ecosystem matures, expect this process to get smoother. The Next.js team and the React core team are actively working on better testing primitives for RSCs. For now, the patterns in this article give you a reliable foundation that covers the most common scenarios without requiring complex infrastructure.
For further reading, see the Next.js testing documentation and the MSW documentation.
Top comments (0)