В архітектурі розподілених вебдодатків автоматизоване тестування виступає гарантом стабільності контрактів даних, запобігаючи регресійним збоям під час рефакторингу, оновлень залежностей та масштабування кодової бази.
Специфіка тестування 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 перехоплює HTTP-запити без підміни глобального інтерфейсу 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) за сучасними стандартами?
Метод 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 затримки може не вистачити, а на локальній машині виконання штучно сповільнюється.
Рішення: Використовуйте веб-орієнтовані автоочікуючі асерти Playwright: await expect(locator).toBeVisible().
Серверні екшени використовують нативні серверні API Node.js (криптографію, читання заголовків, з'єднання з БД). Запуск таких тестів у віртуальному DOM (jsdom) призводить до несумісності глобальних об'єктів.
Рішення: Вказуйте директиву середовища // @vitest-environment node на початку кожного тестового файлу Server Actions.