En la arquitectura App Router de Next.js 15, el paradigma de renderizado está dividido en dos universos aislados: el contexto de hidratación del cliente y el contexto de ejecución del servidor. Los React Server Components (RSC) redefinen por completo la aproximación a las pruebas automatizadas de interfaz.
El método tradicional basado en React Testing Library (RTL) y emuladores del navegador (jsdom, happy-dom) dependía de invocar síncronamente render(<Component />) para luego consultar el árbol del DOM virtual. No obstante, los componentes del servidor presentan tres propiedades que invalidan las herramientas clásicas de RTL:
async function ServerComponent(props) y devuelve Promise<JSX.Element>. El runtime de React Testing Library no admite renderizar componentes asíncronos en contenedores de cliente y arroja: Objects are not valid as a React child (found: [object Promise]).useEffect), estado local (useState), gestores de eventos del DOM (onClick) y acceso al objeto global window. Testearlos dentro de jsdom genera sobrecarga artificial y enmascara defectos arquitectónicos.cookies(), cabeceras con headers() y ejecutan consultas SQL en Prisma o Drizzle sin intermediación de APIs REST/GraphQL.Por ende, el estándar de pruebas de RSC se articula así: probar el componente de servidor como una función asíncrona pura en entorno Node.js, aislando la capa de datos y los contextos de servidor de Next.js.
En el modelo mental de pruebas de RSC, el componente se percibe como una canalización asíncrona de transformación de entradas (props, parámetros de ruta, cabeceras, estado de la base de datos) en una estructura inmutable de descriptores del árbol virtual (React Flight Protocol).
Dado que el componente devuelve un árbol de elementos JSX (objetos con type, props y children), la prueba consiste en resolver la promesa await ServerComponent(props) y verificar la estructura devuelta, o renderizarla hacia una cadena HTML estática mediante react-dom/server.
+-------------------------------------------------------------------------+
| Test Runner Vitest (Node.js) |
+-------------------------------------------------------------------------+
│
├─► 1. Mocking: next/headers (cookies(), headers())
├─► 2. Mocking: Data Access Layer (Prisma / Drizzle / fetch)
│
▼
+-------------------------------------------------------------------------+
| Invocación Asíncrona del Componente de Servidor |
| const jsx = await ServerComponent({ params }) |
+-------------------------------------------------------------------------+
│
├────────────────────────────────────────┬────────────────────────┤
▼ ▼ ▼
+-----------------------+ +------------------+ +----------------+
| Inspección del JSX | | react-dom/server | | Verificación |
| - jsx.type | | renderToString() | | Side Effects: |
| - jsx.props.children | | HTML Assertions | | redirect() |
| - props componentes | | (Cheerio/Regex) | | notFound() |
| de cliente | | | | |
+-----------------------+ +------------------+ +----------------+En Next.js 15, los parámetros de ruta params y los parámetros de consulta searchParams en páginas y layouts de servidor se han transformado en promesas asíncronas (Promise<T>). Esto exige adaptar la construcción de props en los tests.
redirect('/login') y ante registros inexistentes se dispare notFound().'use client'): Confirmar con precisión qué datos cruzan hacia componentes interactivos de cliente para evitar exponer atributos confidenciales del servidor.Para aislar las pruebas de servidor, se establece un perfil en Vitest configurado con entorno nativo node, omitiendo el sobrecoste de happy-dom y polyfills de navegador.
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
// Aislamiento de componentes de servidor en entorno nativo de Node.js
environment: 'node',
include: ['src/**/*.server.test.{ts,tsx}'],
alias: {
'@': path.resolve(__dirname, './src'),
},
coverage: {
provider: 'v8',
reporter: ['text', 'json'],
},
},
});El componente recupera registros mediante el DAL, lee cookies de sesión asíncronamente en Next.js 15 (await cookies()) y transfiere datos serializados a un botón interactivo de cliente.
import { notFound, redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { getUserById } from '@/dal/users';
import { EditProfileButton } from './EditProfileButton';
export interface ProfilePageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ tab?: string }>;
}
export default async function ProfilePage({ params, searchParams }: ProfilePageProps) {
// En Next.js 15 params y searchParams devuelven Promises
const { id } = await params;
const { tab = 'overview' } = await searchParams;
// En Next.js 15 cookies() devuelve Promise<ReadonlyRequestCookies>
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session_token')?.value;
if (!sessionToken) {
redirect('/auth/login');
}
const user = await getUserById(id);
if (!user) {
notFound();
}
return (
<main className="profile-container" data-testid="profile-page">
<header>
<h1>{user.fullName}</h1>
<p className="email">{user.email}</p>
<span className="current-tab">Pestaña activa: {tab}</span>
</header>
<section className="profile-content">
<EditProfileButton userId={user.id} initialRole={user.role} />
</section>
</main>
);
}Para evaluar el marcado resultante se emplea renderToString de react-dom/server, ejecutando el árbol virtual hacia una cadena HTML en Node.js.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderToString } from 'react-dom/server';
import ProfilePage from './page';
import * as dal from '@/dal/users';
import { cookies } from 'next/headers';
import { redirect, notFound } from 'next/navigation';
// Mock de módulos nativos de servidor de Next.js
vi.mock('next/headers', () => ({
cookies: vi.fn(),
}));
vi.mock('next/navigation', () => ({
redirect: vi.fn().mockImplementation(() => {
throw new Error('NEXT_REDIRECT');
}),
notFound: vi.fn().mockImplementation(() => {
throw new Error('NEXT_NOT_FOUND');
}),
}));
vi.mock('@/dal/users', () => ({
getUserById: vi.fn(),
}));
// Mock del componente de cliente para auditar props
vi.mock('./EditProfileButton', () => ({
EditProfileButton: (props: { userId: string; initialRole: string }) => (
<button data-testid="edit-btn" data-user-id={props.userId} data-role={props.initialRole}>
Editar
</button>
),
}));
describe('ProfilePage (React Server Component)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renderiza con éxito el perfil cuando la sesión es válida', async () => {
// Configuración del mock asíncrono de cookies() para Next.js 15
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue({ value: 'valid-jwt-token' }),
} as unknown as Awaited<ReturnType<typeof cookies>>);
// Mock del Data Access Layer
vi.mocked(dal.getUserById).mockResolvedValue({
id: 'usr-100',
fullName: 'Alejandro Arquitecto',
email: '[email protected]',
role: 'lead_engineer',
});
// Parámetros asíncronos según Next.js 15
const props = {
params: Promise.resolve({ id: 'usr-100' }),
searchParams: Promise.resolve({ tab: 'security' }),
};
// Invocación asíncrona directa del RSC
const jsxTree = await ProfilePage(props);
// Conversión a HTML
const html = renderToString(jsxTree);
expect(html).toContain('Alejandro Arquitecto');
expect(html).toContain('[email protected]');
expect(html).toContain('Pestaña activa: security');
expect(html).toContain('data-role="lead_engineer"');
expect(dal.getUserById).toHaveBeenCalledWith('usr-100');
});
it('invoca redirect("/auth/login") cuando falta la cookie de sesión', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue(undefined),
} as unknown as Awaited<ReturnType<typeof cookies>>);
const props = {
params: Promise.resolve({ id: 'usr-100' }),
searchParams: Promise.resolve({}),
};
await expect(ProfilePage(props)).rejects.toThrow('NEXT_REDIRECT');
expect(redirect).toHaveBeenCalledWith('/auth/login');
});
it('invoca notFound() cuando el registro no existe en la base de datos', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue({ value: 'valid-jwt' }),
} as unknown as Awaited<ReturnType<typeof cookies>>);
vi.mocked(dal.getUserById).mockResolvedValue(null);
const props = {
params: Promise.resolve({ id: 'usr-unknown' }),
searchParams: Promise.resolve({}),
};
await expect(ProfilePage(props)).rejects.toThrow('NEXT_NOT_FOUND');
expect(notFound).toHaveBeenCalled();
});
});Cuando se requiere comprobar con rigor qué props se transfieren a la frontera del componente de cliente sin recurrir al parseo de texto HTML, se puede examinar el objeto descriptor React Element directamente.
import { describe, it, expect, vi } from 'vitest';
import ProfilePage from './page';
import * as dal from '@/dal/users';
import { cookies } from 'next/headers';
import { ReactElement } from 'react';
vi.mock('next/headers');
vi.mock('@/dal/users');
describe('ProfilePage: JSX Structure Inspection', () => {
it('transfiere props estrictamente válidas a la frontera del cliente', async () => {
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue({ value: 'active-session' }),
} as unknown as Awaited<ReturnType<typeof cookies>>);
vi.mocked(dal.getUserById).mockResolvedValue({
id: 'usr-555',
fullName: 'Elena Smirnova',
email: '[email protected]',
role: 'staff_security',
});
const jsx = await ProfilePage({
params: Promise.resolve({ id: 'usr-555' }),
searchParams: Promise.resolve({}),
});
// Validar tipo de elemento raíz
expect(jsx.type).toBe('main');
// Localizar sección que contiene el botón
const section = (jsx.props.children as ReactElement[]).find(
(child) => child.props.className === 'profile-content'
);
expect(section).toBeDefined();
const clientButton = section?.props.children as ReactElement;
expect(clientButton.props).toEqual({
userId: 'usr-555',
initialRole: 'staff_security',
});
});
});Ejecutar render(<AsyncServerComponent />) produce un error irrecuperable en el runtime de React porque React Testing Library está concebido exclusivamente para componentes síncronos de cliente.
// ❌ ERROR: RTL no soporta componentes async
import { render, screen } from '@testing-library/react';
it('falla en el montaje', () => {
render(<ProfilePage params={Promise.resolve({ id: '1' })} searchParams={Promise.resolve({})} />);
});
// ✅ SOLUCIÓN: Invocación asíncrona directa de la función del componente
const jsx = await ProfilePage({
params: Promise.resolve({ id: '1' }),
searchParams: Promise.resolve({}),
});
const html = renderToString(jsx);
expect(html).toContain('Encabezado de perfil');En Next.js 15, cookies(), headers(), params y searchParams operan mediante el modelo asíncrono (Promise). Retornar objetos síncronos en los mocks provocará TypeError: cookies(...).get is not a function.
// ❌ ERROR: Mock síncrono incompatible con Next.js 15
vi.mocked(cookies).mockReturnValue({
get: () => ({ value: 'token' }),
} as unknown as ReturnType<typeof cookies>);
// ✅ SOLUCIÓN: Devolver una promesa con mockResolvedValue
vi.mocked(cookies).mockResolvedValue({
get: vi.fn().mockReturnValue({ value: 'token' }),
} as unknown as Awaited<ReturnType<typeof cookies>>);Simular jsdom ralentiza la suite de tests entre 3 y 5 veces y genera la impresión errónea de que APIs de navegador (localStorage, window.location) están presentes, las cuales fallarán indefectiblemente en producción al correr en el servidor. Utilice estrictamente environment: 'node'.
Las utilidades redirect() y notFound() de Next.js interrumpen el renderizado arrojando errores internos del sistema (NEXT_REDIRECT, NEXT_NOT_FOUND). Si el mock no simula este comportamiento, la ejecución continuará, generando errores de tipo TypeError al intentar leer propiedades de entidades nulas.
Asegurar que la suite corra en entorno 'node', sin DOM virtual ni polyfills de cliente.
Establecer mocks asíncronos para next/headers (cookies, headers) y next/navigation (redirect, notFound).
Mockear repositorios u ORMs (Drizzle/Prisma) impidiendo conexiones de red directas a la base de datos.
Empaquetar params y searchParams en Promises resueltas según Next.js 15.
Resolver await Component(props) para obtener el objeto descriptor del árbol virtual de React.
Contrastar el HTML generado con renderToString o auditar las props suministradas a componentes de cliente.