Server Actions represent the cornerstone of data mutations in modern fullstack React 19 and Next.js 15 architectures.
Under legacy client-server patterns, every mutation required authoring a REST controller or GraphQL resolver, wiring up manual client networking layers (fetch / axios), managing form submission states, and invalidating client-side caches (React Query / SWR) by hand. Server Actions eliminate intermediate endpoints, linking interface interactions directly to server logic across an RPC boundary.
From a testing architecture standpoint, Server Actions present a fundamental advantage: they are standard asynchronous JavaScript functions executing within a Node.js server runtime. They do not necessitate resource-heavy browser E2E test suites (Playwright / Cypress) for unit and integration verification. Testing Server Actions within Vitest offers distinct benefits:
revalidatePath, revalidateTag) can be asserted directly.In this testing model, a Server Action is treated as an isolated server transaction controller. It accepts raw or semi-typed inputs (a plain JavaScript object or a FormData instance), executes runtime schema validation, evaluates session context (Authentication & Authorization), executes database transactions, and triggers server-side effects (Next.js cache invalidation and redirects).
+-------------------------------------------------------------------------+
| Vitest Integration Test (Node.js) |
+-------------------------------------------------------------------------+
│
├─► Initialize Session (Mock auth() / cookies())
├─► Prepare Fixture (FormData / JSON Payload)
│
▼
+-------------------------------------------------------------------------+
| Server Action ('use server') |
+-------------------------------------------------------------------------+
│
├─► 1. Validate Input (Zod / Valibot) ──► Error 422
├─► 2. Validate RBAC Session (Session Guard) ──► Error 401/403
├─► 3. Database Transaction (Drizzle / Prisma) ──► Rollback on error
│
▼
+-------------------------------------------------------------------------+
| Server-Side Side Effects |
+-------------------------------------------------------------------------+
│
├─► revalidatePath('/dashboard') / revalidateTag('projects')
├─► redirect('/dashboard/projects')
│
▼
+-------------------------------------------------------------------------+
| Verify Return Contract: Discriminated Union Result |
| expect(result).toEqual({ success: true, data: ... }) |
+-------------------------------------------------------------------------+FormData emitted by React 19 forms, verifying Zod schema integration and returning granular field errors.member role cannot mutate third-party resources or delete organizations, even when supplying valid entity identifiers.revalidatePath() and revalidateTag() upon successful entity creation or mutation.Following enterprise design principles, Server Action return contracts are modeled as Discriminated Unions, eliminating unhandled runtime exceptions on the client.
'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, 'Title must contain at least 3 characters').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. Session verification
const user = await getCurrentUser();
if (!user) {
return { success: false, error: 'Authentication required' };
}
if (user.role !== 'admin' && user.role !== 'team_lead') {
return { success: false, error: 'Insufficient permissions to create project' };
}
// 2. Input validation
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: 'Validation failed',
fieldErrors: validationResult.error.flatten().fieldErrors,
};
}
// 3. Database mutation
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. Invalidate Next.js caches
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: 'Internal database error occurred while creating project' };
}
}We invoke the Server Action directly as an asynchronous function, supplying an authentic FormData instance and asserting Next.js mock calls.
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';
// Mock Next.js runtime modules
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
vi.mock('@/lib/auth', () => ({
getCurrentUser: vi.fn(),
}));
// Mock Drizzle ORM database interface
vi.mock('@/lib/db', () => ({
db: {
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
returning: vi.fn(),
},
}));
describe('createProjectAction (Integration Test)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates project successfully, invalidates caches, and returns record ID', async () => {
// 1. Arrange: Authenticated 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 Architectural Refactor');
formData.append('description', 'Migration to App Router and React 19');
formData.append('priority', 'high');
// 2. Act: Invoke Server Action
const result = await createProjectAction(null, formData);
// 3. Assert: Verify outcome
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('proj-999');
}
// Verify DB insertion payload
expect(db.values).toHaveBeenCalledWith({
title: 'Next.js Architectural Refactor',
description: 'Migration to App Router and React 19',
priority: 'high',
ownerId: 'usr-42',
});
// Verify cache invalidations
expect(revalidatePath).toHaveBeenCalledWith('/dashboard/projects');
expect(revalidateTag).toHaveBeenCalledWith('projects-list');
});
it('returns authentication error for unauthenticated requests', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null);
const formData = new FormData();
formData.append('title', 'Unauthorized Project');
formData.append('priority', 'low');
const result = await createProjectAction(null, formData);
expect(result).toEqual({
success: false,
error: 'Authentication required',
});
expect(db.insert).not.toHaveBeenCalled();
expect(revalidatePath).not.toHaveBeenCalled();
});
it('rejects mutation when RBAC privileges are insufficient', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({
id: 'usr-guest',
email: '[email protected]',
role: 'guest',
});
const formData = new FormData();
formData.append('title', 'New Project');
formData.append('priority', 'medium');
const result = await createProjectAction(null, formData);
expect(result).toEqual({
success: false,
error: 'Insufficient permissions to create project',
});
expect(db.insert).not.toHaveBeenCalled();
});
it('returns Zod field validation errors for invalid inputs', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({
id: 'usr-admin',
email: '[email protected]',
role: 'admin',
});
const formData = new FormData();
formData.append('title', 'AB'); // Below 3 chars
formData.append('priority', 'invalid_priority'); // Invalid enum value
const result = await createProjectAction(null, formData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Validation failed');
expect(result.fieldErrors?.title).toContain('Title must contain at least 3 characters');
expect(result.fieldErrors?.priority).toBeDefined();
}
expect(db.insert).not.toHaveBeenCalled();
});
});When a Server Action terminates with an explicit user redirect via redirect(), Next.js throws an internal NEXT_REDIRECT sentinel error. Tests must assert this control flow exception.
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('deletes account and throws NEXT_REDIRECT to /auth/goodbye', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ id: 'usr-delete-me' });
// Assert action throws the redirect sentinel exception
await expect(deleteAccountAction()).rejects.toThrow('NEXT_REDIRECT: /auth/goodbye');
expect(redirect).toHaveBeenCalledWith('/auth/goodbye');
});
});Throwing unhandled exceptions from Server Actions causes Next.js to obscure the message in production, returning a generic error hash (digest: '...') for security. The client loses the ability to present meaningful validation messages to the user.
// ❌ ERROR: Exception is obfuscated by Next.js runtime in production
if (!isValid) {
throw new Error('Invalid email address');
}
// ✅ SOLUTION: Return a typed Discriminated Union
if (!isValid) {
return { success: false, error: 'Invalid email address' };
}Next.js redirect() works by throwing an error. A test executing await myAction() that triggers redirect() will fail with Unhandled Rejection: NEXT_REDIRECT if the rejection is not asserted.
// ❌ ERROR: Test crashes due to unhandled rejection
await myActionWithRedirect();
// ✅ SOLUTION: Assert redirect rejection
await expect(myActionWithRedirect()).rejects.toThrow('NEXT_REDIRECT');
expect(redirect).toHaveBeenCalledWith('/target-path');Invoking revalidatePath() prior to transaction commitment or outside a try block risks invalidating caches when the mutation ultimately fails, leading to stale or inconsistent cache reads. Tests should strictly enforce invocation sequence.
Never accept userId as an input parameter from a client component. User identity must always be extracted on the server from the cryptographically signed session (await getCurrentUser()). Tests must verify that forged client IDs are discarded.
Configure session mock auth() with the required role (admin, member, guest, unauthenticated).
Construct a FormData instance populated with valid, boundary, and malformed inputs.
Invoke the Server Action directly as an async function: await action(null, formData).
Verify that correct payload arguments were delivered to the database layer (ORM/Repository).
Verify calls to revalidatePath() and revalidateTag() with exact route/tag segments.
Assert ActionState schema: success flag, mutation data, or fieldErrors dictionary.