Testing React Server Components in Next.js
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
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.
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
asyncfunctions that return JSX. This affects how you render and assert in tests. - Next.js‑specific APIs. Functions like
next/navigation’sredirect()ornotFound(), and thenext/headersmodule, 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
| Issue | Fix |
|---|---|
window is not defined | Your test environment is set to jsdom. Change jest.config.js to testEnvironment: 'node'. |
Cannot read properties of undefined on Next.js imports | Mock 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 correctly | await the component before passing it to renderToString: const component = await MyComponent(props); renderToString(component); |
| Fetch mock not being called | Ensure 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 CI | Verify 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:
- Use a
nodetest environment instead ofjsdom. - Call async components directly (
await MyComponent()) rather than usingrender(). - 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.