Los Server Actions constituyen la base de las mutaciones de datos en la arquitectura fullstack moderna de React 19 y Next.js 15.
En el enfoque tradicional de interacción cliente-servidor, para cada operación de mutación el desarrollador creaba un controlador REST o un resolver de GraphQL, escribía una capa de red manual (fetch / axios), gestionaba el estado de envío del formulario e invalidaba manualmente la caché del cliente (React Query / SWR). Los Server Actions eliminan la necesidad de endpoints intermediarios, conectando la interfaz con la lógica del servidor de forma directa a través de una barrera RPC.
Desde el punto de vista de la arquitectura de testing, un Server Action ofrece una ventaja clave: es una función asíncrona de JavaScript estándar que se ejecuta en el entorno de servidor Node.js. No es necesario probarlo exclusivamente mediante pesadas pruebas E2E en el navegador (Playwright / Cypress). Las pruebas de integración de Server Actions en el entorno Vitest:
revalidatePath, revalidateTag).En el modelo mental de testing, un Server Action se concibe como un controlador transaccional de servidor aislado. Recibe una entrada no tipada o semitipada (un objeto JavaScript plano o una instancia de FormData), realiza una validación de esquema estricta en tiempo de ejecución, comprueba el contexto del usuario actual (Authentication & Authorization), ejecuta la mutación en la capa de persistencia y desencadena efectos secundarios en el servidor (invalidación de caché de Next.js y redirecciones).
+-------------------------------------------------------------------------+
| Prueba de Integración Vitest (Node.js) |
+-------------------------------------------------------------------------+
│
├─► Inicialización de sesión (Mock auth() / cookies())
├─► Preparación de fixture (FormData / Payload JSON)
│
▼
+-------------------------------------------------------------------------+
| Server Action ('use server') |
+-------------------------------------------------------------------------+
│
├─► 1. Validación de entrada (Zod / Valibot) ──► Error 422
├─► 2. Validación RBAC de sesión (Session Guard) ──► Error 401/403
├─► 3. Transacción en BD (Drizzle / Prisma) ──► Rollback en fallo
│
▼
+-------------------------------------------------------------------------+
| Efectos Secundarios en Servidor |
+-------------------------------------------------------------------------+
│
├─► revalidatePath('/dashboard') / revalidateTag('projects')
├─► redirect('/dashboard/projects')
│
▼
+-------------------------------------------------------------------------+
| Verificación del resultado: Discriminated Union Result |
| expect(result).toEqual({ success: true, data: ... }) |
+-------------------------------------------------------------------------+FormData crudo procedente de formularios cliente de React 19 con integración de esquemas Zod y retorno de errores detallados por campo.member no pueda modificar recursos ajenos ni eliminar organizaciones, incluso si proporciona un identificador de entidad válido.revalidatePath() y revalidateTag() tras la creación o actualización exitosa de entidades.Siguiendo las mejores prácticas de arquitectura empresarial, el resultado de un Server Action se modela como una unión discriminada (Discriminated Union), evitando el escape de excepciones no controladas hacia el cliente.
'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, 'El título debe contener al menos 3 caracteres').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. Verificación de sesión de usuario
const user = await getCurrentUser();
if (!user) {
return { success: false, error: 'Se requiere autenticación en el sistema' };
}
if (user.role !== 'admin' && user.role !== 'team_lead') {
return { success: false, error: 'Permisos insuficientes para crear proyectos' };
}
// 2. Validación de datos de entrada del formulario
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: 'Error de validación en los campos',
fieldErrors: validationResult.error.flatten().fieldErrors,
};
}
// 3. Ejecución de la mutación en la base de datos
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. Invalidación de caché en 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: 'Error interno de base de datos al crear el proyecto' };
}
}Probamos el Server Action directamente como una función asíncrona, suministrando una instancia real de FormData y validando las llamadas a los mocks de 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';
// Mockeamos dependencias del runtime de Next.js
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
vi.mock('@/lib/auth', () => ({
getCurrentUser: vi.fn(),
}));
// Mockeamos la interfaz de base de datos 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('crea con éxito el proyecto, invalida la caché y retorna el id de entidad', async () => {
// 1. Arrange: Team Lead autenticado
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', 'Refactorización Arquitectónica Next.js');
formData.append('description', 'Migración a App Router y React 19');
formData.append('priority', 'high');
// 2. Act: llamada al Server Action
const result = await createProjectAction(null, formData);
// 3. Assert: verificación del resultado
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('proj-999');
}
// Verificación de argumentos de inserción en BD
expect(db.values).toHaveBeenCalledWith({
title: 'Refactorización Arquitectónica Next.js',
description: 'Migración a App Router y React 19',
priority: 'high',
ownerId: 'usr-42',
});
// Verificación de llamadas de invalidación de caché
expect(revalidatePath).toHaveBeenCalledWith('/dashboard/projects');
expect(revalidateTag).toHaveBeenCalledWith('projects-list');
});
it('retorna error de autorización para solicitudes no autenticadas', async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null);
const formData = new FormData();
formData.append('title', 'Proyecto Espía');
formData.append('priority', 'low');
const result = await createProjectAction(null, formData);
expect(result).toEqual({
success: false,
error: 'Se requiere autenticación en el sistema',
});
expect(db.insert).not.toHaveBeenCalled();
expect(revalidatePath).not.toHaveBeenCalled();
});
it('rechaza la mutación ante privilegios de rol insuficientes (RBAC)', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({
id: 'usr-guest',
email: '[email protected]',
role: 'guest',
});
const formData = new FormData();
formData.append('title', 'Nuevo Proyecto');
formData.append('priority', 'medium');
const result = await createProjectAction(null, formData);
expect(result).toEqual({
success: false,
error: 'Permisos insuficientes para crear proyectos',
});
expect(db.insert).not.toHaveBeenCalled();
});
it('retorna errores de campos Zod cuando los datos del formulario son inválidos', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({
id: 'usr-admin',
email: '[email protected]',
role: 'admin',
});
const formData = new FormData();
formData.append('title', 'AB'); // Menos de 3 caracteres
formData.append('priority', 'invalid_priority'); // Enum inválido
const result = await createProjectAction(null, formData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Error de validación en los campos');
expect(result.fieldErrors?.title).toContain('El título debe contener al menos 3 caracteres');
expect(result.fieldErrors?.priority).toBeDefined();
}
expect(db.insert).not.toHaveBeenCalled();
});
});Cuando un Server Action concluye forzando la redirección del usuario mediante redirect(), es crucial recordar que en Next.js el método redirect() lanza deliberadamente una excepción especial NEXT_REDIRECT. La prueba debe verificar la captura de dicha excepción.
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('elimina la cuenta y lanza NEXT_REDIRECT hacia /auth/goodbye', async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ id: 'usr-delete-me' });
// Verificamos que la acción lance la excepción de redirección
await expect(deleteAccountAction()).rejects.toThrow('NEXT_REDIRECT: /auth/goodbye');
expect(redirect).toHaveBeenCalledWith('/auth/goodbye');
});
});Lanzar una excepción no controlada desde un Server Action provoca que Next.js la capture y devuelva al cliente un error ofuscado con un hash (digest: '...'), ocultando el mensaje por motivos de seguridad en producción. La interfaz de cliente pierde la capacidad de informar al usuario la causa real del fallo.
// ❌ INCORRECTO: La excepción es enmascarada por el runtime de Next.js en producción
if (!isValid) {
throw new Error('Email incorrecto');
}
// ✅ CORRECTO: Retorno de una Discriminated Union tipada
if (!isValid) {
return { success: false, error: 'Email incorrecto' };
}El método redirect() en Next.js funciona arrojando una excepción (throw). Si en una prueba se ejecuta await myAction() y la acción invoca redirect(), la prueba fallará con Unhandled Rejection: NEXT_REDIRECT si el desarrollador no espera explícitamente el rechazo de la promesa.
// ❌ INCORRECTO: La prueba fallará debido a una excepción no controlada
await myActionWithRedirect();
// ✅ CORRECTO: Esperamos la excepción de redirección
await expect(myActionWithRedirect()).rejects.toThrow('NEXT_REDIRECT');
expect(redirect).toHaveBeenCalledWith('/target-path');Si se invoca revalidatePath() antes de que la transacción de base de datos se confirme o fuera del bloque try, la invalidación de la caché podría ejecutarse ante un posterior fallo de la mutación, lo que inducirá al almacenamiento en caché de estados corruptos o inconsistentes. En las pruebas debe vigilarse rigurosamente el orden de las llamadas.
Nunca pase el identificador del usuario actual (userId) como parámetro de un Server Action desde un componente de cliente. El identificador de usuario siempre debe resolverse en el servidor a partir de una sesión firmada criptográficamente (await getSession()). En las pruebas, valide siempre que la acción ignore userId falsificados.
Configurar el mock de sesión auth() con el rol requerido (admin, member, guest, unauthenticated).
Construir una instancia de FormData con valores límite y cargas inválidas.
Ejecutar el Server Action directamente como función asíncrona: await action(null, formData).
Verificar la exactitud de los argumentos suministrados a la capa de base de datos (ORM/Repository).
Comprobar llamadas a revalidatePath() y revalidateTag() con segmentos de cadena exactos.
Validar la estructura de ActionState: bandera success, datos de mutación o mapa de errores fieldErrors.