In the Next.js 15 App Router architecture, rendering is fundamentally bifurcated into two isolated environments: the client hydration context and the server execution context. React Server Components (RSC) fundamentally redefine automated UI testing.
The historical approach employing React Testing Library (RTL) and browser emulators (jsdom, happy-dom) relied on synchronously calling render(<Component />) followed by polling the virtual DOM tree. However, server components exhibit three traits that render classic RTL tooling inapplicable:
async function ServerComponent(props) and returns Promise<JSX.Element>. React Testing Library's runtime cannot render asynchronous components directly into a client container and throws: Objects are not valid as a React child (found: [object Promise]).useEffect), local state (useState), DOM event dispatchers (onClick), and the global window object. Executing them inside jsdom introduces artificial overhead and masks architecture faults.cookies(), headers via headers(), and execute database queries via Prisma or Drizzle without intermediary REST/GraphQL APIs.Consequently, the standard for testing RSCs is formulated as follows: testing the server component as a pure asynchronous server function in a Node.js environment with isolated data access layers and Next.js server contexts.
In the RSC testing mental model, a server component is evaluated as an asynchronous transformation pipeline converting input parameters (props, route params, request headers, database state) into an immutable virtual tree descriptor (React Flight Protocol).
Because server components produce a JSX tree (objects with type, props, and children), testing reduces to awaiting the await ServerComponent(props) promise and inspecting the returned object structure, or serializing to static HTML via react-dom/server.
+-------------------------------------------------------------------------+
| Vitest Test Runner (Node.js) |
+-------------------------------------------------------------------------+
│
├─► 1. Mocking: next/headers (cookies(), headers())
├─► 2. Mocking: Data Access Layer (Prisma / Drizzle / fetch)
│
▼
+-------------------------------------------------------------------------+
| Invoke Asynchronous Server Component |
| const jsx = await ServerComponent({ params }) |
+-------------------------------------------------------------------------+
│
├────────────────────────────────────────┬────────────────────────┤
▼ ▼ ▼
+-----------------------+ +------------------+ +----------------+
| Inspect JSX Tree | | react-dom/server | | Verify Side |
| - jsx.type | | renderToString() | | Effects: |
| - jsx.props.children | | HTML Assertions | | redirect() |
| - props of client | | (Cheerio/Regex) | | notFound() |
| components | | | | |
+-----------------------+ +------------------+ +----------------+In Next.js 15, route parameters params and query string parameters searchParams in server pages and layouts are asynchronous (Promise<T>). This asynchronous contract must be reflected when constructing test fixtures.
redirect('/login') and non-existent entities trigger notFound().'use client'): Confirming which props cross the serialization barrier into interactive client components to prevent leaking sensitive server attributes.To isolate server tests, create a dedicated Vitest configuration targeting a clean node environment, eliminating happy-dom overhead and unnecessary browser polyfills.
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
// Isolate server components in native Node.js runtime
environment: 'node',
include: ['src/**/*.server.test.{ts,tsx}'],
alias: {
'@': path.resolve(__dirname, './src'),
},
coverage: {
provider: 'v8',
reporter: ['text', 'json'],
},
},
});The component fetches records via DAL, reads session cookies asynchronously in Next.js 15 (await cookies()), and passes serialized data to an interactive client button.
import { notFound, redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { getUserById } from '@/dal/users';
import { EditProfileButton } from './EditProfileButton';
export interface ProfilePageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ tab?: string }>;
}
export default async function ProfilePage({ params, searchParams }: ProfilePageProps) {
// In Next.js 15 params and searchParams return Promises
const { id } = await params;
const { tab = 'overview' } = await searchParams;
// In Next.js 15 cookies() returns Promise<ReadonlyRequestCookies>
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session_token')?.value;
if (!sessionToken) {
redirect('/auth/login');
}
const user = await getUserById(id);
if (!user) {
notFound();
}
return (
<main className="profile-container" data-testid="profile-page">
<header>
<h1>{user.fullName}</h1>
<p className="email">{user.email}</p>
<span className="current-tab">Active tab: {tab}</span>
</header>
<section className="profile-content">
<EditProfileButton userId={user.id} initialRole={user.role} />
</section>
</main>
);
}To verify the rendered markup, we use renderToString from react-dom/server, executing the virtual component tree to an HTML string inside Node.js.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderToString } from 'react-dom/server';
import ProfilePage from './page';
import * as dal from '@/dal/users';
import { cookies } from 'next/headers';
import { redirect, notFound } from 'next/navigation';
// Mock Next.js built-in server modules
vi.mock('next/headers', () => ({
cookies: vi.fn(),
}));
vi.mock('next/navigation', () => ({
redirect: vi.fn().mockImplementation(() => {
throw new Error('NEXT_REDIRECT');
}),
notFound: vi.fn().mockImplementation(() => {
throw new Error('NEXT_NOT_FOUND');
}),
}));
vi.mock('@/dal/users', () => ({
getUserById: vi.fn(),
}));
// Stub client component to inspect forwarded props
vi.mock('./EditProfileButton', () => ({
EditProfileButton: (props: { userId: string; initialRole: string }) => (
<button data-testid="edit-btn" data-user-id={props.userId} data-role={props.initialRole}>
Edit
</button>
),
}));
describe('ProfilePage (React Server Component)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders profile data successfully when an authenticated session exists', async () => {
// Configure Next.js 15 async cookies() mock
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue({ value: 'valid-jwt-token' }),
} as unknown as Awaited<ReturnType<typeof cookies>>);
// Mock DAL response
vi.mocked(dal.getUserById).mockResolvedValue({
id: 'usr-100',
fullName: 'Alex Architect',
email: '[email protected]',
role: 'lead_engineer',
});
// Construct Next.js 15 async props
const props = {
params: Promise.resolve({ id: 'usr-100' }),
searchParams: Promise.resolve({ tab: 'security' }),
};
// Directly invoke async RSC
const jsxTree = await ProfilePage(props);
// Render virtual tree to HTML string
const html = renderToString(jsxTree);
expect(html).toContain('Alex Architect');
expect(html).toContain('[email protected]');
expect(html).toContain('Active tab: security');
expect(html).toContain('data-role="lead_engineer"');
expect(dal.getUserById).toHaveBeenCalledWith('usr-100');
});
it('invokes redirect("/auth/login") when the session cookie is missing', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue(undefined),
} as unknown as Awaited<ReturnType<typeof cookies>>);
const props = {
params: Promise.resolve({ id: 'usr-100' }),
searchParams: Promise.resolve({}),
};
await expect(ProfilePage(props)).rejects.toThrow('NEXT_REDIRECT');
expect(redirect).toHaveBeenCalledWith('/auth/login');
});
it('invokes notFound() when the record does not exist in the database', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue({ value: 'valid-jwt' }),
} as unknown as Awaited<ReturnType<typeof cookies>>);
vi.mocked(dal.getUserById).mockResolvedValue(null);
const props = {
params: Promise.resolve({ id: 'usr-unknown' }),
searchParams: Promise.resolve({}),
};
await expect(ProfilePage(props)).rejects.toThrow('NEXT_NOT_FOUND');
expect(notFound).toHaveBeenCalled();
});
});When testing that exact props are passed into a client component boundary without running string parsing, you can inspect the returned React Element descriptor directly.
import { describe, it, expect, vi } from 'vitest';
import ProfilePage from './page';
import * as dal from '@/dal/users';
import { cookies } from 'next/headers';
import { ReactElement } from 'react';
vi.mock('next/headers');
vi.mock('@/dal/users');
describe('ProfilePage: JSX Structure Inspection', () => {
it('passes strictly valid props across the client component boundary', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue({ value: 'active-session' }),
} as unknown as Awaited<ReturnType<typeof cookies>>);
vi.mocked(dal.getUserById).mockResolvedValue({
id: 'usr-555',
fullName: 'Elena Smirnova',
email: '[email protected]',
role: 'staff_security',
});
const jsx = await ProfilePage({
params: Promise.resolve({ id: 'usr-555' }),
searchParams: Promise.resolve({}),
});
// Check root element type
expect(jsx.type).toBe('main');
// Locate section holding the button
const section = (jsx.props.children as ReactElement[]).find(
(child) => child.props.className === 'profile-content'
);
expect(section).toBeDefined();
const clientButton = section?.props.children as ReactElement;
expect(clientButton.props).toEqual({
userId: 'usr-555',
initialRole: 'staff_security',
});
});
});Invoking render(<AsyncServerComponent />) triggers a fatal React runtime error because React Testing Library is architected strictly for synchronous client components.
// ❌ ERROR: RTL cannot render async components
import { render, screen } from '@testing-library/react';
it('crashes on mount', () => {
render(<ProfilePage params={Promise.resolve({ id: '1' })} searchParams={Promise.resolve({})} />);
});
// ✅ SOLUTION: Direct asynchronous invocation of the component function
const jsx = await ProfilePage({
params: Promise.resolve({ id: '1' }),
searchParams: Promise.resolve({}),
});
const html = renderToString(jsx);
expect(html).toContain('Profile Heading');In Next.js 15, cookies(), headers(), params, and searchParams are asynchronous (Promise). Returning objects synchronously from mocks triggers TypeError: cookies(...).get is not a function.
// ❌ ERROR: Synchronous mock crashes Next.js 15 runtime
vi.mocked(cookies).mockReturnValue({
get: () => ({ value: 'token' }),
} as unknown as ReturnType<typeof cookies>);
// ✅ SOLUTION: Return promise via mockResolvedValue
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue({ value: 'token' }),
} as unknown as Awaited<ReturnType<typeof cookies>>);Simulating jsdom slows test runs by 3–5x and creates the illusion that browser APIs (localStorage, window.location) are available, which will crash in production on the server. Always set environment: 'node' for server tests.
In Next.js, redirect() and notFound() intentionally interrupt rendering by throwing internal sentinel errors (NEXT_REDIRECT, NEXT_NOT_FOUND). If your mock does not throw, the component continues executing, causing unexpected TypeError exceptions downstream.
Ensure the test suite runs in the 'node' environment without virtual DOM or browser polyfills.
Configure asynchronous mocks for next/headers (cookies, headers) and next/navigation (redirect, notFound).
Mock repository methods or ORMs (Drizzle/Prisma) to prevent actual database connections.
Wrap params and searchParams in resolved Promises adhering to Next.js 15 specifications.
Await the component invocation await Component(props) to obtain the root virtual tree descriptor.
Assert rendered HTML via renderToString or inspect props passed into child client components.