В архитектуре распределенных веб-приложений автоматизированное тестирование выступает гарантом стабильности контрактов данных, исключая регрессионные сбои при рефакторинге, обновлениях зависимостей и масштабировании кодовой базы.
Специфика тестирования Next.js App Router заключается в гибридном разделении рантаймов: приложение объединяет асинхронный серверный код (React Server Components, Server Actions, Route Handlers) и интерактивный клиентский интерфейс (Client Components с хуками React 19). Классические подходы времен SPA (монолитный запуск Jest в эмуляторе jsdom) оказываются неэффективными:
revalidatePath, revalidateTag) и транзакций базы данных.global.fetch.Стратегия тестирования Next.js строится на четырехуровневой пирамиде с разделением ответственности:
┌─────────────────────────────────────────────────────────┐
│ E2E ТЕСТЫ │
│ (Playwright) │
│ Сквозные пользовательские сценарии │
│ Авторизация, Оформление заказа │
└────────────────────────────┬────────────────────────────┘
│ покрывает
┌────────────────────────────▼────────────────────────────┐
│ ИНТЕГРАЦИОННЫЕ ТЕСТЫ │
│ (Vitest + RTL + MSW) │
│ Компоненты со сложным состоянием │
│ Формы, Фильтры каталога, Корзина │
└────────────────────────────┬────────────────────────────┘
│ покрывает
┌────────────────────────────▼────────────────────────────┐
│ МОДУЛЬНЫЕ ТЕСТЫ │
│ (Vitest Environment Node) │
│ Server Actions, Схемы Zod, Чистые утилиты │
└────────────────────────────┬────────────────────────────┘
│ базируется на
┌────────────────────────────▼────────────────────────────┐
│ СТАТИЧЕСКИЙ АНАЛИЗ │
│ TypeScript (tsc --noEmit) + ESLint │
└─────────────────────────────────────────────────────────┘Проверка типов TypeScript (tsc --noEmit) и линтинг ESLint.
Запуск Vitest в среде Node.js для Server Actions, схем Zod и чистых утилит.
Рендеринг UI-компонентов в jsdom с сетевым мокированием через MSW.
Сборка production-билда (pnpm run build) для верификации компиляции.
Исполнение сквозных тестов Playwright в headless-браузерах на staging-окружении.
Разработчик написал модульный тест для Server Action, создающего заказ в базе данных и вызывающего revalidatePath('/orders'). Тест запускается в стандартном окружении Vitest с jsdom, но падает с ошибкой 'ReferenceError: Request/Headers is not defined' и жалуется на криптографию Node.js. Как правильно организовать тест?
Установка необходимых тестовых утилит осуществляется через пакетный менеджер pnpm:
pnpm add -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event mswКонфигурационный файл настраивает алиасы путей и подключение глобального файла настройки:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
alias: {
'@': path.resolve(__dirname, './src'),
},
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', '.next/', '**/*.d.ts'],
},
},
});Глобальный файл инициализации подключает сопоставители @testing-library/jest-dom и настраивает сервер MSW с автоматическим сбросом состояния между тестами (Test Isolation):
import '@testing-library/jest-dom';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './src/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());@testing-library/user-event#Современный тест компонента эмулирует реальное взаимодействие человека:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Компонент Button', () => {
it('корректно отображает текст и реагирует на клик', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Отправить заявку</Button>);
const button = screen.getByRole('button', { name: /отправить заявку/i });
expect(button).toBeInTheDocument();
await user.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('блокирует нажатие в состоянии disabled', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>Заблокировано</Button>);
const button = screen.getByRole('button', { name: /заблокировано/i });
expect(button).toBeDisabled();
await user.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
});next/navigation#При тестировании компонентов, использующих хуки роутера Next.js (useRouter, usePathname, useSearchParams), необходимо изолировать контекст маршрутизатора:
import { vi } from 'vitest';
export function setupNavigationMock(overrides = {}) {
const pushMock = vi.fn();
const replaceMock = vi.fn();
const refreshMock = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: pushMock,
replace: replaceMock,
refresh: refreshMock,
prefetch: vi.fn(),
back: vi.fn(),
...overrides,
}),
usePathname: () => '/dashboard',
useSearchParams: () => new URLSearchParams('page=1&sort=desc'),
}));
return { pushMock, replaceMock, refreshMock };
}MSW перехватывает сетевые запросы без подмены протокола fetch:
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.agenticlab.dev/v1/user/profile', () => {
return HttpResponse.json({
id: 'usr-1',
name: 'Александр Сеньоров',
role: 'ADMIN',
});
}),
http.post('https://api.agenticlab.dev/v1/analytics', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ received: body }, { status: 201 });
}),
];Паттерн Page Object Model (POM) исключает дублирование локаторов в тестах Playwright:
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel(/email/i);
this.passwordInput = page.getByLabel(/пароль/i);
this.submitButton = page.getByRole('button', { name: /войти/i });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, pass: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(pass);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}Сам тестовый сценарий читается декларативно:
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test.describe('Сценарий авторизации пользователя', () => {
test('успешный вход перенаправляет в личный кабинет', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'CorrectPassword123!');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Панель управления');
});
test('отображает ошибку при неверных учетных данных', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'WrongPassword!');
await loginPage.expectError('Неверный логин или пароль');
await expect(page).toHaveURL('/login');
});
});В CI/CD пайплайне E2E-тест чекаута регулярно падает со случайными ошибками. В коде теста разработчик написал: await page.click('#submit-btn'); await page.waitForTimeout(3000); expect(await page.textContent('.status')).toBe('Заказ оформлен');. Как устранить нестабильность (Flakiness) по стандартам 2026 года?
Метод fireEvent.click(button) моментально триггерит только одно DOM-событие click. Он не проверяет, заблокирован ли элемент атрибутом disabled, находится ли он за оверлеем и получил ли фокус. В результате тест проходит успешно, но реальный пользователь в браузере не может нажать на кнопку.
Решение: Всегда используйте библиотеку @testing-library/user-event с инициализацией через const user = userEvent.setup().
Если переопределить сетевой ответ через server.use(http.get(...)) внутри одного теста и не очистить его в хуке afterEach, переопределенный хэндлер утечет в последующие тесты, вызывая каскадные сбои в независимых модулях.
Решение: Всегда вызывайте server.resetHandlers() в глобальном файле vitest.setup.ts.
Вызовы await page.waitForTimeout(2000) превращают тестовый сьют в генератор нестабильных тестов (Flaky Test). На слабой CI-машине задержки в 2 секунды может не хватить (тест упадет), а на локальной машине выполнение искусственно замедляется.
Решение: Используйте автоожидающие локаторы Playwright: await expect(locator).toBeVisible().
Серверные экшены используют нативные серверные API Node.js (криптографию, чтение заголовков, ORM). Запуск таких тестов в виртуальном DOM (jsdom) приводит к несовместимости глобальных объектов.
Решение: Указывайте директиву среды // @vitest-environment node в начале каждого тестового файла Server Actions.