В архітектурі Next.js 15 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 15 параметри маршруту params та параметри рядка запиту searchParams у серверних сторінках та лейаутах стали асинхронними (Promise<T>). Це необхідно враховувати при конструюванні тестових пропсів.
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 15 (асинхронний виклик 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 15 params та searchParams повертають Promise
const { id } = await params;
const { tab = 'overview' } = await searchParams;
// У Next.js 15 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 15
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 15
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',
});
});
});Виклик 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 15 функції cookies(), headers(), а також аргументи сторінок params та searchParams переведено на асинхронну модель (Promise). Спроба синхронно повернути об'єкт з мока призведе до помилки TypeError: cookies(...).get is not a function.
// ❌ ПОМИЛКА: Синхронний мок ламає рантайм Next.js 15
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.
Переконатися, що тест виконується в середовищі 'node', без віртуального DOM та поліфілів браузера.
Налаштувати асинхронні моки для next/headers (cookies, headers) та next/navigation (redirect, notFound).
Замокати методи репозиторію або ORM (Drizzle/Prisma), запобігаючи реальним мережевим з'єднанням до бази.
Сформувати проміси params та searchParams відповідно до специфікації Next.js 15.
Виконати await Component(props) та отримати кореневий об'єкт віртуального дерева React.
Звірити згенерований HTML через renderToString або провалідувати пропси дочірніх клієнтських компонентів.