Server Actions представляют собой фундамент мутаций данных в современной полнофункциональной архитектуре React 19 и Next.js 16.
В традиционном подходе клиент-серверного взаимодействия для каждой операции мутации разработчик создавал REST-контроллер или GraphQL-резолвер, писал ручной сетевой слой (fetch / axios), управлял состоянием отправки формы и вручную очищал клиентский кэш (React Query / SWR). Server Actions устраняют необходимость в промежуточных эндпоинтах, связывая интерфейс с серверной логикой напрямую через RPC-барьер.
С точки зрения архитектуры тестирования Server Action обладает ключевым преимуществом: это обычная асинхронная JavaScript-функция, исполняющаяся в серверном окружении Node.js. Ее не требуется тестировать исключительно через тяжелые E2E-тесты в браузере (Playwright / Cypress). Интеграционные тесты Server Actions в среде Vitest:
revalidatePath, revalidateTag).В ментальной модели тестирования Server Action рассматривается как изолированный серверный транзакционный контроллер. Он принимает нетипизированный или полутипизированный ввод (сырой объект JavaScript или экземпляр FormData), выполняет строгую runtime-валидацию схемы, проверяет контекст текущего пользователя (Authentication & Authorization), производит мутацию в слое базы данных и инициирует серверные побочные эффекты (инвалидация кэша Next.js и редирект).
+-------------------------------------------------------------------------+
| Интеграционный тест Vitest (Node.js) |
+-------------------------------------------------------------------------+
│
├─► Инициализация сессии (Mock auth() / cookies())
├─► Подготовка фикстуры (FormData / JSON Payload)
│
▼
+-------------------------------------------------------------------------+
| Server Action ('use server') |
+-------------------------------------------------------------------------+
│
├─► 1. Валидация входных данных (Zod / Valibot) ──► Ошибка 422
├─► 2. Проверка RBAC сессии (Session Guard) ──► Ошибка 401/403
├─► 3. Транзакция в БД (Drizzle / Prisma) ──► Rollback при сбое
│
▼
+-------------------------------------------------------------------------+
| Серверные побочные эффекты |
+-------------------------------------------------------------------------+
│
├─► revalidatePath('/dashboard') / revalidateTag('projects')
├─► redirect('/dashboard/projects')
│
▼
+-------------------------------------------------------------------------+
| Верификация результата: Discriminated Union Result |
| expect(result).toEqual({ success: true, data: ... }) |
+-------------------------------------------------------------------------+Сконфигурировать мок сессии auth() с требуемой ролью (admin, member, guest, unauthenticated).
Сформировать экземпляр FormData с граничными и невалидными значениями полей.
Вызвать Server Action напрямую как асинхронную функцию: await action(null, formData).
Удостовериться в корректности переданных аргументов в слой базы данных (ORM/Repository).
Проверить вызовы revalidatePath() и revalidateTag() с точными строковыми сегментами.
Провалидировать структуру ActionState: поле success, данные мутации либо карту ошибок fieldErrors.
Почему для интеграционного тестирования Server Actions в Next.js 16 не требуется запуск браузера и сетевых клиентов (fetch/axios)?
FormData из клиентских форм React 19 с интеграцией схем Zod и возвратом детализированных ошибок для полей формы.member не может изменить чужой ресурс или удалить организацию, даже если передал корректный идентификатор сущности.revalidatePath() и revalidateTag() после успешного создания или обновления сущностей.В соответствии с лучшими практиками enterprise-архитектуры результат Server Action оформляется как дискриминантное объединение (Discriminated Union), исключая выброс неотловленных исключений на клиент.
'use server';
import { z } from 'zod';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { projects } from '@/lib/schema';
import { getCurrentUser } from '@/lib/auth';
const CreateProjectSchema = z.object({
title: z.string().trim().min(3, 'Название должно содержать минимум 3 символа').max(100),
description: z.string().trim().max(500).optional(),
priority: z.enum(['low', 'medium', 'high', 'critical']),
});
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export type ActionState<T> =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record<string, string[]> };
export async function createProjectAction(
prevState: ActionState<{ id: string }> | null,
formData: FormData
): Promise<ActionState<{ id: string }>> {
// 1. Проверка сессии пользователя
const user = await getCurrentUser();
if (!user) {
return { success: false, error: 'Требуется авторизация в системе' };
}
if (user.role !== 'admin' && user.role !== 'team_lead') {
return { success: false, error: 'Недостаточно прав для создания проекта' };
}
// 2. Валидация входных данных формы
const rawData = {
title: formData.get('title'),
description: formData.get('description') || undefined,
priority: formData.get('priority'),
};
const validationResult = CreateProjectSchema.safeParse(rawData);
if (!validationResult.success) {
return {
success: false,
error: 'Ошибка валидации полей',
fieldErrors: validationResult.error.flatten().fieldErrors,
};
}
// 3. Выполнение мутации в базе данных
try {
const [newProject] = await db
.insert(projects)
.values({
title: validationResult.data.title,
description: validationResult.data.description,
priority: validationResult.data.priority,
ownerId: user.id,
})
.returning({ id: projects.id });
// 4. Сброс кэша Next.js
revalidatePath('/dashboard/projects');
revalidateTag('projects-list');
return { success: true, data: { id: newProject.id } };
} catch (error) {
console.error('Database mutation error:', error);
return { success: false, error: 'Внутренняя ошибка базы данных при создании проекта' };
}
}Тестируем Server Action напрямую как асинхронную функцию, передавая реальный экземпляр FormData и проверяя вызовы моков Next.js.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createProjectAction } from './projects';
import { db } from '@/lib/db';
import { getCurrentUser } from '@/lib/auth';
import { revalidatePath, revalidateTag } from 'next/cache';
// Мокируем зависимости рантайма Next.js
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
vi.mock('@/lib/auth', () => ({
getCurrentUser: vi.fn(),
}));
// Мокируем интерфейс базы данных Drizzle ORM
vi.mock('@/lib/db', () => ({
db: {
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn(),
},
}));
describe('createProjectAction (Integration Test)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('успешно создает проект, сбрасывает кэш и возвращает id сущности', async () => {
// 1. Arrange: авторизованный Team Lead
vi.mocked(getCurrentUser).mockResolvedValue({
id: 'usr-42',
email: '[email protected]',
role: 'team_lead',
});
vi.mocked(db.returning).mockResolvedValue([{ id: 'proj-999' }]);
const formData = new FormData();
formData.append('title', 'Архитектурный рефакторинг Next.js');
formData.append('description', 'Переход на App Router и React 19');
formData.append('priority', 'high');
// 2. Act: вызов серверного экшена
const result = await createProjectAction(null, formData);
// 3. Assert: проверка результата
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('proj-999');
}
// Проверка аргументов вставки в БД
expect(db.values).toHaveBeenCalledWith({
title: 'Архитектурный рефакторинг Next.js',
description: 'Переход на App Router и React 19',
priority: 'high',
ownerId: 'usr-42',
});
// Проверка вызовов инвалидации кэша Next.js
expect(revalidatePath).toHaveBeenCalledWith('/dashboard/projects');
expect(revalidateTag).toHaveBeenCalledWith('projects-list');
});
it('возвращает ошибку авторизации для неаутентифицированного запроса', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null);
const formData = new FormData();
formData.append('title', 'Проект-шпион');
formData.append('priority', 'low');
const result = await createProjectAction(null, formData);
expect(result).toEqual({
success: false,
error: 'Требуется авторизация в системе',
});
expect(db.insert).not.toHaveBeenCalled();
expect(revalidatePath).not.toHaveBeenCalled();
});
it('отклоняет мутацию при недостатке ролевых привилегий (RBAC)', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({
id: 'usr-guest',
email: '[email protected]',
role: 'guest',
});
const formData = new FormData();
formData.append('title', 'Новый проект');
formData.append('priority', 'medium');
const result = await createProjectAction(null, formData);
expect(result).toEqual({
success: false,
error: 'Недостаточно прав для создания проекта',
});
expect(db.insert).not.toHaveBeenCalled();
});
it('возвращает ошибки полей Zod при невалидных данных формы', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({
id: 'usr-admin',
email: '[email protected]',
role: 'admin',
});
const formData = new FormData();
formData.append('title', 'AB'); // Меньше 3 символов
formData.append('priority', 'invalid_priority'); // Недопустимый enum
const result = await createProjectAction(null, formData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Ошибка валидации полей');
expect(result.fieldErrors?.title).toContain('Название должно содержать минимум 3 символа');
expect(result.fieldErrors?.priority).toBeDefined();
}
expect(db.insert).not.toHaveBeenCalled();
});
});Когда Server Action завершается принудительным перенаправлением пользователя через redirect(), важно помнить, что в Next.js метод redirect() намеренно выбрасывает специальное исключение NEXT_REDIRECT. Тест должен проверять перехват этого исключения.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { deleteAccountAction } from './delete-account';
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth';
import { db } from '@/lib/db';
vi.mock('next/navigation', () => ({
redirect: vi.fn().mockImplementation((url: string) => {
const error = new Error(`NEXT_REDIRECT: ${url}`);
(error as unknown as { digest: string }).digest = `NEXT_REDIRECT;replace;${url};307;`;
throw error;
}),
}));
vi.mock('@/lib/auth');
vi.mock('@/lib/db');
describe('deleteAccountAction with redirect', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('удаляет аккаунт и выбрасывает NEXT_REDIRECT на /auth/goodbye', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ id: 'usr-delete-me' });
// Проверяем, что экшен бросает исключение редиректа
await expect(deleteAccountAction()).rejects.toThrow('NEXT_REDIRECT: /auth/goodbye');
expect(redirect).toHaveBeenCalledWith('/auth/goodbye');
});
});Почему при тестировании Server Action, вызывающего redirect(), тест должен ожидать выброс исключения (rejects.toThrow)?
Выброс необработанного исключения из Server Action приводит к тому, что Next.js перехватывает его и возвращает на клиент обезличенную ошибку с хэшем (digest: '...'), скрывая сообщение в целях безопасности продакшена. Клиентский интерфейс теряет возможность показать пользователю понятную причину сбоя.
// ❌ ОШИБКА: Исключение скрывается рантаймом Next.js в проде
if (!isValid) {
throw new Error('Некорректный email');
}
// ✅ ПРАВИЛЬНО: Возврат типизированного Discriminated Union
if (!isValid) {
return { success: false, error: 'Некорректный email' };
}Метод redirect() в Next.js работает через throw. Если в тесте запустить await myAction() и экшен вызовет redirect(), тест упадет с ошибкой Unhandled Rejection: NEXT_REDIRECT, если разработчик не ожидает падение промиса.
// ❌ ОШИБКА: Тест упадет из-за unhandled exception
await myActionWithRedirect();
// ✅ ПРАВИЛЬНО: Ожидаем исключение редиректа
await expect(myActionWithRedirect()).rejects.toThrow('NEXT_REDIRECT');
expect(redirect).toHaveBeenCalledWith('/target-path');Если вызвать revalidatePath() до коммита транзакции базы данных или вне блока try, сброс кэша может произойти при последующем падении мутации, что приведет к кэшированию некорректного или промежуточного состояния. В тестах необходимо строго контролировать очередность вызовов.
Никогда не передавайте идентификатор текущего пользователя (userId) в качестве параметра Server Action из клиентского компонента. Идентификатор пользователя всегда должен извлекаться на сервере из криптографически подписанной сессии (await getSession()). В тестах обязательно проверяйте, что экшен игнорирует поддельные userId.