В архитектуре Next.js 16 App Router парадигма рендеринга фундаментально разделена на два изолированных мира: клиентский контекст гидратации и серверный контекст исполнения. React Server Components (RSC) кардинально меняют подход к автоматизированному тестированию интерфейсов.
Исторический подход с использованием React Testing Library (RTL) и браузерных эмуляторов (jsdom, happy-dom) базировался на синхронном вызове render(<Component />) с последующим опросом виртуального DOM дерева. Однако серверные компоненты обладают тремя свойствами, которые делают классический инструментарий RTL неприменимым:
async function ServerComponent(props) и возвращает Promise<JSX.Element>. React Testing Library на уровне внутреннего рантайма не поддерживает рендеринг асинхронных компонентов в клиентский контейнер и выбрасывает исключение: Objects are not valid as a React child (found: [object Promise]).useEffect), локального состояния (useState), контекста событий DOM (onClick) и доступа к глобальному объекту window. Попытка тестировать их внутри jsdom создает искусственный оверхед и маскирует архитектурные ошибки.cookies(), заголовки через headers(), а также выполняют SQL-запросы к Prisma или Drizzle без промежуточного REST/GraphQL API.Следовательно, стандарт тестирования RSC в 2026 году формулируется так: тестирование серверного компонента как чистой асинхронной серверной функции в среде Node.js с изоляцией слоя данных и серверных контекстов Next.js.
В ментальной модели тестирования RSC серверный компонент рассматривается как асинхронный конвейер преобразования входных данных (пропсы, параметры маршрута, заголовки запроса, состояние базы данных) в неизменяемую структуру дескрипторов виртуального дерева (React Flight Protocol).
Поскольку серверный компонент возвращает дерево JSX-элементов (объекты с полями type, props, children), тестирование сводится к вычислению промиса await ServerComponent(props) и исследованию полученной структуры объектов, либо рендерингу в статический HTML через react-dom/server.
+-------------------------------------------------------------------------+
| Тестовый раннер Vitest (Node.js) |
+-------------------------------------------------------------------------+
│
├─► 1. Mocking: next/headers (cookies(), headers())
├─► 2. Mocking: Data Access Layer (Prisma / Drizzle / fetch)
│
▼
+-------------------------------------------------------------------------+
| Вызов асинхронного серверного компонента |
| const jsx = await ServerComponent({ params }) |
+-------------------------------------------------------------------------+
│
├────────────────────────────────────────┬────────────────────────┤
▼ ▼ ▼
+-----------------------+ +------------------+ +----------------+
| Инспекция JSX Tree | | react-dom/server | | Проверка side |
| - jsx.type | | renderToString() | | effects: |
| - jsx.props.children | | HTML Assertions | | redirect() |
| - props клиентских | | (Cheerio/Regex) | | notFound() |
| компонентов | | | | |
+-----------------------+ +------------------+ +----------------+В Next.js 16 параметры маршрута params и параметры строки запроса searchParams в серверных страницах и лейаутах являются асинхронными (Promise<T>). Это необходимо учитывать при конструировании тестовых пропсов.
Убедиться, что тест выполняется в среде 'node', без виртуального DOM и полифилов браузера.
Настроить асинхронные моки для next/headers (cookies, headers) и next/navigation (redirect, notFound).
Замокать методы репозитория или ORM (Drizzle/Prisma), предотвращая реальные сетевые соединения к базе.
Сформировать промисы params и searchParams в соответствии со спецификацией Next.js 16.
Выполнить await Component(props) и получить корневой объект виртуального дерева React.
Сверить сгенерированный HTML через renderToString или провалидировать пропсы дочерних клиентских компонентов.
Почему React Server Components (RSC) в Next.js 16 нельзя тестировать через классический вызов render() из React Testing Library в среде jsdom?
redirect('/login'), а при отсутствии запрашиваемой сущности — notFound().'use client'): Верификация того, какие именно данные сервер передает через границу сериализации в интерактивные клиентские компоненты (предотвращение утечки конфиденциальных серверных полей).fallback-компонентов.Для изоляции серверных тестов создается отдельный профиль или проект в Vitest с чистым окружением node, исключающим накладные расходы happy-dom и полифилы браузера.
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
// Изолируем серверные компоненты в нативном Node.js окружении
environment: 'node',
include: ['src/**/*.server.test.{ts,tsx}'],
alias: {
'@': path.resolve(__dirname, './src'),
},
coverage: {
provider: 'v8',
reporter: ['text', 'json'],
},
},
});Компонент запрашивает данные через DAL, считывает сессионные куки в Next.js 16 (асинхронный вызов cookies()) и передает сериализованные данные в клиентскую кнопку редактирования.
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) {
// В Next.js 16 params и searchParams возвращают Promise
const { id } = await params;
const { tab = 'overview' } = await searchParams;
// В Next.js 16 cookies() возвращает 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">Активная вкладка: {tab}</span>
</header>
<section className="profile-content">
<EditProfileButton userId={user.id} initialRole={user.role} />
</section>
</main>
);
}Для тестирования разметки используется утилита renderToString из react-dom/server, которая исполняет скомпонованное виртуальное дерево в строку HTML в среде 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';
// Мокируем встроенные модули Next.js
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(),
}));
// Заглушка для клиентского компонента, чтобы валидировать его пропсы
vi.mock('./EditProfileButton', () => ({
EditProfileButton: (props: { userId: string; initialRole: string }) => (
<button data-testid="edit-btn" data-user-id={props.userId} data-role={props.initialRole}>
Редактировать
</button>
),
}));
describe('ProfilePage (React Server Component)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('успешно рендерит данные профиля при наличии валидной сессии', async () => {
// Настраиваем мок асинхронного cookies() для Next.js 16
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue({ value: 'valid-jwt-token' }),
} as unknown as Awaited<ReturnType<typeof cookies>>);
// Настраиваем данные из DAL
vi.mocked(dal.getUserById).mockResolvedValue({
id: 'usr-100',
fullName: 'Алексей Архитектор',
email: '[email protected]',
role: 'lead_engineer',
});
// Формируем асинхронные параметры Next.js 16
const props = {
params: Promise.resolve({ id: 'usr-100' }),
searchParams: Promise.resolve({ tab: 'security' }),
};
// Вызываем асинхронный RSC напрямую
const jsxTree = await ProfilePage(props);
// Преобразуем виртуальное дерево в HTML строку
const html = renderToString(jsxTree);
expect(html).toContain('Алексей Архитектор');
expect(html).toContain('[email protected]');
expect(html).toContain('Активная вкладка: security');
expect(html).toContain('data-role="lead_engineer"');
expect(dal.getUserById).toHaveBeenCalledWith('usr-100');
});
it('вызывает redirect("/auth/login"), если кука сессии отсутствует', 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('вызывает notFound(), если пользователь не найден в базе данных', 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();
});
});В ситуациях, когда требуется строго проверить, какие пропсы сервер передает в клиентский компонент без запуска парсинга строк, можно исследовать возвращенный объект React Element.
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('передает корректные пропсы в границу клиентского компонента', 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: 'Елена Смирнова',
email: '[email protected]',
role: 'staff_security',
});
const jsx = await ProfilePage({
params: Promise.resolve({ id: 'usr-555' }),
searchParams: Promise.resolve({}),
});
// Проверяем тип корневого элемента
expect(jsx.type).toBe('main');
// Находим секцию с кнопкой
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',
});
});
});Как правильно замокать вызов cookies() из next/headers и пропсы params в тестах страниц Next.js 16?
Вызов render(<AsyncServerComponent />) приводит к фатальной ошибке рантайма React, так как React Testing Library рассчитан на синхронные клиентские компоненты.
// ❌ ОШИБКА: RTL не умеет работать с async компонентами
import { render, screen } from '@testing-library/react';
it('ломается на этапе рендера', () => {
render(<ProfilePage params={Promise.resolve({ id: '1' })} searchParams={Promise.resolve({})} />);
});
// ✅ РЕШЕНИЕ: Прямой асинхронный вызов функции компонента
const jsx = await ProfilePage({
params: Promise.resolve({ id: '1' }),
searchParams: Promise.resolve({}),
});
const html = renderToString(jsx);
expect(html).toContain('Заголовок профиля');В Next.js 16 функции cookies(), headers(), а также аргументы страниц params и searchParams работают по асинхронной модели (Promise). Попытка синхронно вернуть объект из мока приведет к ошибке TypeError: cookies(...).get is not a function.
// ❌ ОШИБКА: Синхронный мок ломает рантайм Next.js 16
vi.mocked(cookies).mockReturnValue({
get: () => ({ value: 'token' }),
} as unknown as ReturnType<typeof cookies>);
// ✅ РЕШЕНИЕ: Возвращаем промис через mockResolvedValue
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue({ value: 'token' }),
} as unknown as Awaited<ReturnType<typeof cookies>>);Эмуляция браузерного окружения jsdom замедляет прогон наборов тестов в 3–5 раз и создает ложное ощущение доступности браузерных API (localStorage, window.location), которые гарантированно приведут к падению в продакшене при серверном выполнении. Для RSC тестов используйте строго environment: 'node'.
Утилиты redirect() и notFound() в Next.js прерывают рендеринг путем выброса специальных системных ошибок (NEXT_REDIRECT, NEXT_NOT_FOUND). Если в моках не симулировать этот throw, выполнение компонента продолжится дальше, вызвав непредвиденные TypeError при обращении к полям user.id от null.