Server Actions становлять фундамент мутацій даних у сучасній повнофункціональній архітектурі React 19 та Next.js 15.
У традиційному підході клієнт-серверної взаємодії для кожної операції мутації розробник створював 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: ... }) |
+-------------------------------------------------------------------------+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 призводить до того, що 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.
Сконфігурувати мок сесії auth() з необхідною роллю (admin, member, guest, unauthenticated).
Сформувати екземпляр FormData з граничними та невалідними значеннями полів.
Викликати Server Action напряму як асинхронну функцію: await action(null, formData).
Упевнитися в коректності переданих аргументів у шар бази даних (ORM/Repository).
Перевірити виклики revalidatePath() та revalidateTag() з точними рядковими сегментами.
Провалідувати структуру ActionState: поле success, дані мутації або карту помилок fieldErrors.