Next.js на App Router - неоднородная среда: часть кода исполняется на сервере, часть в браузере, и одна тестовая настройка на всё это не годится. Официальное руководство прямо ставит границу: юнит-тесты Vitest подходят для Client Components и обычных функций, а асинхронные Server Components в текущем виде юнит-раннер не воспроизводит - их проверяют end-to-end. Понимание этой границы важнее любого конфига: оно определяет, что вообще имеет смысл тестировать на этом уровне.
Практика начинается с отдельного конфига. Файл называют vitest.config.mts - расширение mts берут потому, что у Next.js проекта package.json обычно без type:module, а тестовому конфигу нужен ESM. В плагины ставят @vitejs/plugin-react для JSX и vite-tsconfig-paths, чтобы алиасы путей из tsconfig - те самые @/components - работали и в тестах без ручного дублирования.
Секция test повторяет то, что мы уже знаем: environment 'jsdom' для компонентов, setupFiles с импортом jest-dom, globals по вкусу. Ничего специфично-нексовского здесь нет - это обычный Vite-конфиг, просто живущий рядом с Next.js. Именно поэтому Vitest ложится на Next так естественно: оба стоят на Vite-совместимой трансформации, и второй сборочный тракт не нужен.
Client Component тестируют ровно как любой React-компонент из прошлой главы. Директива 'use client' для jsdom - просто строка в начале файла; сам компонент рендерят через RTL, взаимодействуют через userEvent, проверяют по роли и доступному имени. Всё, что было сказано про формы и очередь запросов, действует здесь без изменений.
Серверную логику - функции загрузки данных, преобразования, обработчики роутов - выносят в обычные модули и тестируют как код в Node-среде, отдельным test project с environment 'node'. Это возвращает нас к правилу разделения: тяжёлую доменную работу держат в чистых функциях, не запаянных в React-дерево, и тогда она проверяется юнит-тестом без всякого рендера.
Асинхронный Server Component - тот, что сам по себе async и ходит за данными, - остаётся за пределами юнита. Раннер не поднимает серверный жизненный цикл App Router: контекст запроса, стриминг, кэш, границы Suspense. Официальная позиция - проверять такие компоненты через Playwright в реальном прогоне; попытка отрендерить их в jsdom даёт либо ложный зелёный, либо загадочные падения на пустом месте.
Отсюда честное распределение по уровням. Клиентские компоненты и чистые серверные функции - юнит на Vitest, быстро и близко к коду. Сквозные сценарии, где участвует серверный рендер, роутинг и данные, - E2E. Не нужно ни тащить в юнит то, что он не умеет, ни выносить в дорогой E2E то, что прекрасно ловится юнитом; каждый уровень берёт свою часть.
Типичный провал - пытаться отрендерить async Server Component в jsdom и, наткнувшись на невнятную ошибку, обмазывать тест моками, пока он не позеленеет. Такой тест не проверяет ничего реального и ломается от любого изменения. Второй провал - единый конфиг на весь проект: серверные функции гоняют в jsdom без нужды, а клиентские не видят DOM. Разделите проекты по среде - и каждый тип кода поедет в своей.
npm i -D @vitejs/plugin-react
// vitest.config.mts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: 'jsdom',
setupFiles: ['./test/setup.ts'], // import '@testing-library/jest-dom/vitest'
},
})test('фильтр обновляет список товаров', async () => {
const user = userEvent.setup()
render(<ProductFilter items={items} />) // 'use client' компонент
await user.click(screen.getByRole('button', { name: /в наличии/i }))
expect(screen.getAllByRole('listitem')).toHaveLength(2)
})