22. Тестирование
🧪 Тестирование Next.js приложений
Заголовок раздела «🧪 Тестирование Next.js приложений»Привет! Сегодня мы разберём одну из самых важных тем в профессиональной разработке — тестирование. Многие разработчики боятся тестов или считают их «скучной работой». Но я тебе скажу кое-что: тесты — это страховка для твоего кода. Написал тест — можешь рефакторить без страха сломать всё! 🛡️
Представь, что ты строишь мост. Ты же не скажешь: «Ну, должен держаться, поехали!» — ты проверяешь каждую балку, каждый болт. То же самое с кодом.
🤔 Зачем вообще тестировать?
Заголовок раздела «🤔 Зачем вообще тестировать?»Без тестов разработка выглядит так:
- Написал фичу ✅
- Проверил вручную в браузере ✅
- Задеплоил в прод ✅
- Через неделю сломал эту же фичу другим изменением 💀
- Клиенты злятся, ты не спишь ночью 😱
С тестами:
- Написал фичу ✅
- Написал тест ✅
- Задеплоил — CI автоматически прогнал все тесты ✅
- Через неделю изменил код — тест упал, ты сразу увидел проблему ✅
- Клиенты счастливы, ты спишь спокойно 😴
Без тестов │ С тестами──────────────────────────────┼──────────────────────────────Рефакторинг = страх │ Рефакторинг = уверенностьБаги в проде │ Баги поймали локально«Оно работало на моей машине» │ CI одинаков для всехРучная проверка каждый раз │ Автоматика за секундыБоишься менять старый код │ Можешь смело переписывать🏛️ Пирамида тестирования
Заголовок раздела «🏛️ Пирамида тестирования»Классическая пирамида Майка Кона:
/\ /E2E\ ← Мало, дорого, медленно /──────\ /Integr. \ ← Средне /────────────\ / Unit Tests \ ← Много, дёшево, быстро /────────────────\Unit тесты — тестируют одну функцию/компонент в изоляции. Быстрые (миллисекунды), дешёвые, их должно быть больше всего.
Integration тесты — тестируют взаимодействие нескольких компонентов вместе. Медленнее, но дают больше уверенности.
E2E (End-to-End) тесты — тестируют весь поток от интерфейса до базы данных. Самые медленные (секунды/минуты), но максимально реалистичные.
Для Next.js это выглядит так:
| Уровень | Инструменты | Что тестируем |
|---|---|---|
| Unit | Jest + RTL | Компоненты, утилиты, хуки |
| Integration | Jest + RTL + MSW | Страницы с API моками |
| E2E | Playwright / Cypress | Полные user flows |
⚙️ Настройка Jest + React Testing Library
Заголовок раздела «⚙️ Настройка Jest + React Testing Library»Первым делом устанавливаем зависимости:
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event ts-jest @types/jestИли если используешь next@14+, у них есть встроенная конфигурация:
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-eventСоздаём jest.config.ts в корне проекта:
import type { Config } from 'jest';import nextJest from 'next/jest';
const createJestConfig = nextJest({ // Путь к Next.js приложению (для загрузки next.config.js и .env файлов) dir: './',});
const config: Config = { // Добавляем кастомную настройку после создания конфига Next.js coverageProvider: 'v8', testEnvironment: 'jsdom',
// Добавляем матчеры из @testing-library/jest-dom setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
// Паттерны для поиска тестов testMatch: [ '**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)', ],
// Алиасы путей (как в tsconfig.json) moduleNameMapper: { '^@/(.*)$': '<rootDir>/$1', },
// Что покрывать collectCoverageFrom: [ 'app/**/*.{ts,tsx}', 'components/**/*.{ts,tsx}', '!**/*.d.ts', '!**/node_modules/**', ],};
// createJestConfig возвращает async функцию, которая Next.js настраивает трансформыexport default createJestConfig(config);Создаём jest.setup.ts:
import '@testing-library/jest-dom';
// Если нужно мокировать window.matchMediaObject.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn().mockImplementation((query) => ({ matches: false, media: query, onchange: null, addListener: jest.fn(), removeListener: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), })),});В package.json добавляем скрипты:
{ "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:ci": "jest --ci --coverage" }}🖥️ Тестирование Client Components
Заголовок раздела «🖥️ Тестирование Client Components»Напишем простой компонент:
'use client';
import { useState } from 'react';
interface CounterProps { initialCount?: number; max?: number;}
export function Counter({ initialCount = 0, max = 10 }: CounterProps) { const [count, setCount] = useState(initialCount);
return ( <div> <h2>Счётчик: {count}</h2> <button onClick={() => setCount((c) => Math.max(0, c - 1))} disabled={count === 0} aria-label="Уменьшить" > − </button> <button onClick={() => setCount((c) => Math.min(max, c + 1))} disabled={count === max} aria-label="Увеличить" > + </button> {count === max && <p>Максимум достигнут!</p>} </div> );}Теперь тест:
import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { Counter } from '../Counter';
describe('Counter', () => { it('рендерит с начальным значением', () => { render(<Counter initialCount={5} />); expect(screen.getByText('Счётчик: 5')).toBeInTheDocument(); });
it('увеличивает счётчик при клике на +', async () => { const user = userEvent.setup(); render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Увеличить' })); expect(screen.getByText('Счётчик: 1')).toBeInTheDocument(); });
it('не уменьшает ниже нуля', async () => { const user = userEvent.setup(); render(<Counter initialCount={0} />);
const decrementBtn = screen.getByRole('button', { name: 'Уменьшить' }); expect(decrementBtn).toBeDisabled(); });
it('показывает сообщение при достижении максимума', async () => { const user = userEvent.setup(); render(<Counter initialCount={9} max={10} />);
await user.click(screen.getByRole('button', { name: 'Увеличить' })); expect(screen.getByText('Максимум достигнут!')).toBeInTheDocument(); });
it('блокирует кнопку + при максимуме', async () => { const user = userEvent.setup(); render(<Counter initialCount={10} max={10} />);
expect(screen.getByRole('button', { name: 'Увеличить' })).toBeDisabled(); });});Важные паттерны RTL:
// ✅ Запросы по роли (семантика!) — предпочтительный способscreen.getByRole('button', { name: 'Отправить' });screen.getByRole('heading', { level: 1 });screen.getByRole('textbox', { name: 'Email' });screen.getByRole('checkbox', { name: 'Согласен' });
// ✅ По текстуscreen.getByText('Привет, мир!');screen.getByText(/привет/i); // regex, case-insensitive
// ✅ По label (для форм)screen.getByLabelText('Email адрес');
// ✅ По placeholderscreen.getByPlaceholderText('Введите email...');
// ✅ По test-id (последний вариант, если нет семантики)screen.getByTestId('user-card');
// getBy vs queryBy vs findBy// getBy — бросает ошибку если не найдено (синхронно)// queryBy — возвращает null если не найдено (для проверки отсутствия)// findBy — возвращает промис (для async элементов)
// Проверкиexpect(element).toBeInTheDocument();expect(element).toBeVisible();expect(element).toBeDisabled();expect(element).toHaveClass('active');expect(element).toHaveTextContent('Загрузка...');🖥️ Тестирование Server Components
Заголовок раздела «🖥️ Тестирование Server Components»Server Components — это async компоненты. Их тестирование немного отличается:
// app/users/UserList.tsx (Server Component)import { prisma } from '@/lib/prisma';
export async function UserList() { const users = await prisma.user.findMany({ take: 10 });
return ( <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> );}import { render, screen } from '@testing-library/react';import { UserList } from '../UserList';
// Мокируем prismajest.mock('@/lib/prisma', () => ({ prisma: { user: { findMany: jest.fn(), }, },}));
import { prisma } from '@/lib/prisma';
describe('UserList', () => { it('отображает список пользователей', async () => { (prisma.user.findMany as jest.Mock).mockResolvedValue([ { id: '1', name: 'Яша' }, { id: '2', name: 'Света' }, ]);
// Server Component — это async функция, её нужно await const Component = await UserList(); render(Component);
expect(screen.getByText('Яша')).toBeInTheDocument(); expect(screen.getByText('Света')).toBeInTheDocument(); });
it('отображает пустой список', async () => { (prisma.user.findMany as jest.Mock).mockResolvedValue([]);
const Component = await UserList(); render(Component);
expect(screen.queryByRole('listitem')).not.toBeInTheDocument(); });});🌐 Мокирование fetch в тестах
Заголовок раздела «🌐 Мокирование fetch в тестах»Часто компоненты делают fetch запросы. В тестах нам нужно их мокировать:
// Простое мокирование через global.fetchdescribe('ProductCard', () => { beforeEach(() => { global.fetch = jest.fn(); });
afterEach(() => { jest.restoreAllMocks(); });
it('отображает данные продукта', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => ({ id: 1, name: 'Ноутбук', price: 50000, }), });
render(<ProductCard id={1} />);
// Ждём появления асинхронного контента expect(await screen.findByText('Ноутбук')).toBeInTheDocument(); expect(screen.getByText('50 000 ₽')).toBeInTheDocument(); });
it('показывает ошибку при неудачном запросе', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 404, });
render(<ProductCard id={999} />);
expect(await screen.findByText('Товар не найден')).toBeInTheDocument(); });
it('показывает загрузку во время запроса', () => { (global.fetch as jest.Mock).mockImplementation( () => new Promise(() => {}) // никогда не резолвится );
render(<ProductCard id={1} />);
expect(screen.getByText('Загрузка...')).toBeInTheDocument(); });});🧭 Мокирование next/navigation
Заголовок раздела «🧭 Мокирование next/navigation»useRouter, usePathname, useSearchParams — всё это нужно мокировать:
// В тесте или в jest.setup.tsjest.mock('next/navigation', () => ({ useRouter: jest.fn(), usePathname: jest.fn(), useSearchParams: jest.fn(), useParams: jest.fn(),}));
import { useRouter, usePathname } from 'next/navigation';
describe('NavBar', () => { it('подсвечивает активную ссылку', () => { (usePathname as jest.Mock).mockReturnValue('/about'); (useRouter as jest.Mock).mockReturnValue({ push: jest.fn(), back: jest.fn(), forward: jest.fn(), });
render(<NavBar />);
const aboutLink = screen.getByRole('link', { name: 'О нас' }); expect(aboutLink).toHaveClass('active'); });
it('навигирует при клике', async () => { const mockPush = jest.fn(); (useRouter as jest.Mock).mockReturnValue({ push: mockPush }); (usePathname as jest.Mock).mockReturnValue('/');
const user = userEvent.setup(); render(<NavBar />);
await user.click(screen.getByRole('button', { name: 'Войти' })); expect(mockPush).toHaveBeenCalledWith('/login'); });});🖼️ Мокирование next/image
Заголовок раздела «🖼️ Мокирование next/image»next/image использует оптимизацию изображений, что может ломать тесты. Мокируем:
import React from 'react';import type { ImageProps } from 'next/image';
const MockImage = ({ src, alt, ...props }: ImageProps) => { // eslint-disable-next-line @next/next/no-img-element return <img src={src as string} alt={alt} {...props} />;};
export default MockImage;Или через jest.mock:
jest.mock('next/image', () => ({ __esModule: true, default: ({ src, alt }: { src: string; alt: string }) => ( <img src={src} alt={alt} /> ),}));📡 Тестирование Route Handlers
Заголовок раздела «📡 Тестирование Route Handlers»Route Handlers (app/api/...) тоже нужно тестировать:
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = Number(searchParams.get('page') ?? 1);
const posts = await fetchPosts(page); return NextResponse.json({ posts, page });}
export async function POST(request: NextRequest) { const body = await request.json();
if (!body.title || !body.content) { return NextResponse.json( { error: 'Поле title и content обязательны' }, { status: 400 } ); }
const post = await createPost(body); return NextResponse.json(post, { status: 201 });}import { GET, POST } from '../route';import { NextRequest } from 'next/server';
jest.mock('@/lib/posts', () => ({ fetchPosts: jest.fn().mockResolvedValue([ { id: 1, title: 'Первый пост' }, { id: 2, title: 'Второй пост' }, ]), createPost: jest.fn().mockResolvedValue({ id: 3, title: 'Новый пост' }),}));
describe('GET /api/posts', () => { it('возвращает список постов', async () => { const request = new NextRequest('http://localhost:3000/api/posts'); const response = await GET(request); const data = await response.json();
expect(response.status).toBe(200); expect(data.posts).toHaveLength(2); expect(data.posts[0].title).toBe('Первый пост'); });
it('принимает параметр page', async () => { const request = new NextRequest('http://localhost:3000/api/posts?page=2'); const response = await GET(request); const data = await response.json();
expect(data.page).toBe(2); });});
describe('POST /api/posts', () => { it('создаёт новый пост', async () => { const request = new NextRequest('http://localhost:3000/api/posts', { method: 'POST', body: JSON.stringify({ title: 'Новый пост', content: 'Текст...' }), });
const response = await POST(request); const data = await response.json();
expect(response.status).toBe(201); expect(data.id).toBe(3); });
it('возвращает 400 при отсутствии title', async () => { const request = new NextRequest('http://localhost:3000/api/posts', { method: 'POST', body: JSON.stringify({ content: 'Текст без заголовка' }), });
const response = await POST(request); const data = await response.json();
expect(response.status).toBe(400); expect(data.error).toContain('title'); });});🎭 MSW — Mock Service Worker
Заголовок раздела «🎭 MSW — Mock Service Worker»MSW — мощный инструмент для мокирования API на уровне сети. Работает в тестах, в браузере, и в Node.js:
npm install --save-dev mswСоздаём хендлеры:
import { http, HttpResponse } from 'msw';
export const handlers = [ // Мокируем GET /api/users http.get('/api/users', () => { return HttpResponse.json([ ]); }),
// Мокируем POST /api/users http.post('/api/users', async ({ request }) => { const body = await request.json() as { name: string; email: string }; return HttpResponse.json( { id: 3, ...body }, { status: 201 } ); }),
// Мокируем с динамическим параметром http.get('/api/users/:id', ({ params }) => { const { id } = params; if (id === '999') { return HttpResponse.json( { error: 'Пользователь не найден' }, { status: 404 } ); } return HttpResponse.json({ id, name: 'Яша' }); }),
// Мокируем внешний API http.get('https://api.github.com/users/:username', ({ params }) => { return HttpResponse.json({ login: params.username, name: 'Test User', public_repos: 42, }); }),];Настраиваем сервер для тестов:
import { setupServer } from 'msw/node';import { handlers } from './handlers';
export const server = setupServer(...handlers);Подключаем в jest.setup.ts:
import '@testing-library/jest-dom';import { server } from '@/mocks/server';
// Запускаем сервер перед всеми тестамиbeforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Сбрасываем хендлеры после каждого тестаafterEach(() => server.resetHandlers());
// Останавливаем сервер после всех тестовafterAll(() => server.close());Теперь тесты с MSW:
import { render, screen, waitFor } from '@testing-library/react';import { server } from '@/mocks/server';import { http, HttpResponse } from 'msw';import { UserList } from '../UserList';
describe('UserList с MSW', () => { it('загружает и отображает пользователей', async () => { render(<UserList />);
// Ждём загрузки expect(await screen.findByText('Яша')).toBeInTheDocument(); expect(screen.getByText('Света')).toBeInTheDocument(); });
it('показывает ошибку при сбое сервера', async () => { // Переопределяем хендлер только для этого теста server.use( http.get('/api/users', () => { return HttpResponse.json( { error: 'Внутренняя ошибка' }, { status: 500 } ); }) );
render(<UserList />);
expect(await screen.findByText('Ошибка загрузки')).toBeInTheDocument(); });});🎭 E2E тесты с Playwright
Заголовок раздела «🎭 E2E тесты с Playwright»Playwright — современный инструмент для E2E тестирования от Microsoft. Быстрее Cypress, поддерживает несколько браузеров:
npm init playwright@latestПосле установки появится playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'html',
use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', },
projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, // Мобильные { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } }, { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } }, ],
// Запускаем Next.js сервер перед тестами webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, },});Пишем первый E2E тест:
import { test, expect } from '@playwright/test';
test.describe('Главная страница', () => { test('отображает заголовок', async ({ page }) => { await page.goto('/');
// Проверяем заголовок страницы await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); await expect(page).toHaveTitle(/Мой сайт/); });
test('навигация работает', async ({ page }) => { await page.goto('/');
// Кликаем на ссылку "О нас" await page.getByRole('link', { name: 'О нас' }).click();
// Проверяем URL await expect(page).toHaveURL('/about'); await expect(page.getByRole('heading', { name: 'О нас' })).toBeVisible(); });
test('мобильное меню', async ({ page }) => { // Устанавливаем мобильный viewport await page.setViewportSize({ width: 375, height: 667 }); await page.goto('/');
// Мобильное меню скрыто await expect(page.getByRole('navigation')).not.toBeVisible();
// Кликаем бургер-кнопку await page.getByRole('button', { name: 'Открыть меню' }).click();
// Меню появилось await expect(page.getByRole('navigation')).toBeVisible(); });});📝 Playwright: тестирование форм
Заголовок раздела «📝 Playwright: тестирование форм»import { test, expect } from '@playwright/test';
test.describe('Аутентификация', () => { test('успешная авторизация', async ({ page }) => { await page.goto('/login');
// Заполняем форму await page.getByLabel('Пароль').fill('password123');
// Отправляем await page.getByRole('button', { name: 'Войти' }).click();
// Ждём редиректа await expect(page).toHaveURL('/dashboard'); await expect(page.getByText('Добро пожаловать!')).toBeVisible(); });
test('показывает ошибку при неверном пароле', async ({ page }) => { await page.goto('/login');
await page.getByLabel('Пароль').fill('wrongpassword'); await page.getByRole('button', { name: 'Войти' }).click();
// Ошибка должна появиться await expect(page.getByText('Неверный email или пароль')).toBeVisible(); // URL не изменился await expect(page).toHaveURL('/login'); });
test('валидация пустых полей', async ({ page }) => { await page.goto('/login');
// Сразу нажимаем без заполнения await page.getByRole('button', { name: 'Войти' }).click();
await expect(page.getByText('Email обязателен')).toBeVisible(); await expect(page.getByText('Пароль обязателен')).toBeVisible(); });
test('полный flow: регистрация → логин → выход', async ({ page }) => { const uniqueEmail = `test-${Date.now()}@example.com`;
// Регистрация await page.goto('/register'); await page.getByLabel('Email').fill(uniqueEmail); await page.getByLabel('Пароль').fill('StrongPass123!'); await page.getByRole('button', { name: 'Зарегистрироваться' }).click(); await expect(page).toHaveURL('/dashboard');
// Выход await page.getByRole('button', { name: 'Выйти' }).click(); await expect(page).toHaveURL('/');
// Повторный вход await page.goto('/login'); await page.getByLabel('Email').fill(uniqueEmail); await page.getByLabel('Пароль').fill('StrongPass123!'); await page.getByRole('button', { name: 'Войти' }).click(); await expect(page).toHaveURL('/dashboard'); });});📸 Snapshot тесты
Заголовок раздела «📸 Snapshot тесты»Snapshot тесты фиксируют «снимок» компонента и сравнивают при следующем запуске:
import { render } from '@testing-library/react';import { Badge } from '../Badge';
describe('Badge snapshots', () => { it('рендерит корректно (success)', () => { const { container } = render( <Badge variant="success">Активен</Badge> ); expect(container).toMatchSnapshot(); });
it('рендерит корректно (error)', () => { const { container } = render( <Badge variant="error">Ошибка</Badge> ); expect(container).toMatchSnapshot(); });});
// Снапшот сохраняется в __snapshots__/Badge.test.tsx.snap// Если намеренно изменил — запусти: jest --updateSnapshot📊 Coverage отчёт
Заголовок раздела «📊 Coverage отчёт»npm run test:coverage
# Вывод в терминале:# ----------------------|---------|----------|---------|---------|# File | % Stmts | % Branch | % Funcs | % Lines |# ----------------------|---------|----------|---------|---------|# All files | 87.45 | 82.10 | 91.30 | 87.45 |# components/Counter.tsx| 100 | 100 | 100 | 100 |# components/NavBar.tsx | 75.00 | 62.50 | 83.33 | 75.00 |
# HTML отчёт в ./coverage/lcov-report/index.htmlВ jest.config.ts устанавливаем пороги:
const config: Config = { // ... coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80, }, // Для конкретных файлов './components/auth/': { branches: 90, lines: 90, }, },};🔄 CI/CD: GitHub Actions
Заголовок раздела «🔄 CI/CD: GitHub Actions»Создаём файл .github/workflows/tests.yml:
name: Tests
on: push: branches: [main, develop] pull_request: branches: [main]
jobs: unit-tests: name: Unit & Integration Tests runs-on: ubuntu-latest
steps: - uses: actions/checkout@v4
- name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm'
- name: Install dependencies run: npm ci
- name: Run tests with coverage run: npm run test:ci
- name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/lcov.info
e2e-tests: name: E2E Tests (Playwright) runs-on: ubuntu-latest needs: unit-tests # Запускаем только если unit тесты прошли
steps: - uses: actions/checkout@v4
- name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm'
- name: Install dependencies run: npm ci
- name: Install Playwright browsers run: npx playwright install --with-deps chromium
- name: Build Next.js app run: npm run build env: DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- name: Run Playwright tests run: npx playwright test env: CI: true
- name: Upload Playwright report uses: actions/upload-artifact@v4 if: always() # Загружаем даже при провале with: name: playwright-report path: playwright-report/ retention-days: 30💡 Советы и лучшие практики
Заголовок раздела «💡 Советы и лучшие практики»1. Тестируй поведение, а не реализацию:
// ❌ Плохо — тестируем внутреннее состояниеexpect(component.state.isOpen).toBe(true);
// ✅ Хорошо — тестируем то, что видит пользовательexpect(screen.getByRole('dialog')).toBeVisible();2. Один тест — одна ответственность:
// ❌ Плохо — слишком много в одном тестеit('форма работает', async () => { // 50 строк кода...});
// ✅ Хорошо — каждый тест про конкретный случайit('показывает ошибку валидации для невалидного email');it('отправляет форму при валидных данных');it('блокирует кнопку во время отправки');3. Используй describe для группировки:
describe('LoginForm', () => { describe('Валидация', () => { it('требует email'); it('требует валидный формат email'); it('требует пароль минимум 8 символов'); });
describe('Отправка', () => { it('показывает лоадер при отправке'); it('редиректит при успехе'); it('показывает ошибку сервера'); });});4. beforeEach для повторяющейся логики:
describe('UserDashboard', () => { beforeEach(() => { // Мокируем авторизованного пользователя перед каждым тестом (useAuth as jest.Mock).mockReturnValue({ user: { id: '1', name: 'Яша', role: 'admin' }, isLoading: false, }); });
it('показывает имя пользователя'); it('показывает кнопку настроек для админа');});Тестирование в Next.js — это:
| Инструмент | Задача |
|---|---|
| Jest | Тест-раннер, моки, coverage |
| React Testing Library | Тестирование компонентов |
| MSW | Мокирование API запросов |
| Playwright | E2E тестирование |
Помни формулу: “Напиши тест → Он красный → Напиши код → Он зелёный → Рефакторинг” — это называется TDD (Test-Driven Development). Попробуй хоть раз — и ты оценишь! 🚀