En la arquitectura de aplicaciones web distribuidas, el testing automatizado es la salvaguarda fundamental para garantizar los contratos de datos, eliminando regresiones durante refactorizaciones, actualizaciones de librerías y el crecimiento del código.
La singularidad de evaluar Next.js App Router reside en la coexistencia híbrida de dos runtimes: el backend asíncrono (React Server Components, Server Actions, Route Handlers) y la interfaz interactiva en el cliente (Client Components con hooks de React 19). Las metodologías tradicionales de la época SPA (como suites de Jest aisladas en entornos simulados de jsdom) resultan insuficientes:
revalidatePath, revalidateTag) y las transacciones de bases de datos.global.fetch.La estrategia de testing en Next.js se articula sobre una pirámide de cuatro niveles con aislamiento de responsabilidades:
┌─────────────────────────────────────────────────────────┐
│ TESTS E2E │
│ (Playwright) │
│ Flujos de usuario de extremo a extremo │
│ Autenticación, Pasarela de pago │
└────────────────────────────┬────────────────────────────┘
│ cubre
┌────────────────────────────▼────────────────────────────┐
│ TESTS DE INTEGRACIÓN │
│ (Vitest + RTL + MSW) │
│ Componentes interactivos complejos │
│ Formularios, Filtros, Carrito │
└────────────────────────────┬────────────────────────────┘
│ cubre
┌────────────────────────────▼────────────────────────────┐
│ TESTS UNITARIOS │
│ (Vitest Environment Node) │
│ Server Actions, Esquemas Zod, Utilidades │
└────────────────────────────┬────────────────────────────┘
│ cimentado en
┌────────────────────────────▼────────────────────────────┐
│ ANÁLISIS ESTÁTICO │
│ TypeScript (tsc --noEmit) + ESLint │
└─────────────────────────────────────────────────────────┘Verificación estricta de tipos de TypeScript (tsc --noEmit) y análisis con ESLint.
Ejecución de Vitest en Node.js puro para Server Actions y validación de esquemas Zod.
Renderizado de componentes en jsdom con intercepción de peticiones HTTP vía MSW.
Compilación de producción (pnpm run build) para validar la integridad del bundle.
Ejecución de suites de Playwright en navegadores headless sobre entornos de staging.
Un desarrollador escribe un test unitario para una Server Action que registra pedidos en la base de datos e invoca revalidatePath('/orders'). Al ejecutar el test en el entorno predeterminado de Vitest con jsdom, falla con 'ReferenceError: Request/Headers is not defined' y anomalías en módulos de criptografía. ¿Cuál es la solución arquitectónica correcta?
La instalación de las dependencias requeridas se realiza con el gestor de paquetes pnpm:
pnpm add -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event mswEl archivo de configuración define los alias de importación y el setup global:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
alias: {
'@': path.resolve(__dirname, './src'),
},
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', '.next/', '**/*.d.ts'],
},
},
});El archivo de inicialización global incorpora los matchers de @testing-library/jest-dom y activa el servidor MSW asegurando Test Isolation:
import '@testing-library/jest-dom';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './src/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());@testing-library/user-event#Las pruebas modernas de interfaz reproducen patrones de interacción humana auténticos:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Componente Button', () => {
it('renderiza el contenido y responde a eventos de clic', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Enviar solicitud</Button>);
const button = screen.getByRole('button', { name: /enviar solicitud/i });
expect(button).toBeInTheDocument();
await user.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('ignora las pulsaciones cuando está deshabilitado', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>Acción bloqueada</Button>);
const button = screen.getByRole('button', { name: /acción bloqueada/i });
expect(button).toBeDisabled();
await user.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
});next/navigation#Al probar componentes que consumen los hooks de navegación de Next.js (useRouter, usePathname, useSearchParams), es indispensable aislar el contexto del enrutador:
import { vi } from 'vitest';
export function setupNavigationMock(overrides = {}) {
const pushMock = vi.fn();
const replaceMock = vi.fn();
const refreshMock = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: pushMock,
replace: replaceMock,
refresh: refreshMock,
prefetch: vi.fn(),
back: vi.fn(),
...overrides,
}),
usePathname: () => '/dashboard',
useSearchParams: () => new URLSearchParams('page=1&sort=desc'),
}));
return { pushMock, replaceMock, refreshMock };
}MSW intercepta las solicitudes de red sin alterar el protocolo fetch:
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.agenticlab.dev/v1/user/profile', () => {
return HttpResponse.json({
id: 'usr-1',
name: 'Alejandro Lead',
role: 'ADMIN',
});
}),
http.post('https://api.agenticlab.dev/v1/analytics', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ received: body }, { status: 201 });
}),
];El patrón Page Object Model (POM) suprime la duplicación de localizadores:
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel(/email/i);
this.passwordInput = page.getByLabel(/contraseña/i);
this.submitButton = page.getByRole('button', { name: /iniciar sesión/i });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, pass: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(pass);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}Las especificaciones de prueba resultan concisas y legibles:
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test.describe('Flujo de autenticación de usuario', () => {
test('inicio de sesión exitoso redirige al panel de control', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'CorrectPassword123!');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Panel de control');
});
test('muestra mensaje de error ante credenciales inválidas', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'WrongPassword!');
await loginPage.expectError('Usuario o contraseña incorrectos');
await expect(page).toHaveURL('/login');
});
});En el pipeline de CI/CD, una prueba E2E de proceso de compra falla de forma intermitente. En el test se lee: await page.click('#submit-btn'); await page.waitForTimeout(3000); expect(await page.textContent('.status')).toBe('Pedido confirmado');. ¿Cómo debe corregirse la inestabilidad (Flakiness) según los estándares modernos?
El método fireEvent.click(button) se limita a disparar un evento DOM aislado de tipo click. No verifica si el elemento cuenta con el atributo disabled, si está tapado por una capa superior o si admite foco. Por consiguiente, el test puede aprobarse mientras que un usuario real sería incapaz de accionar el botón.
Solución: Utilice invariablemente la librería @testing-library/user-event inicializada con const user = userEvent.setup().
Sobrescribir un endpoint con server.use(http.get(...)) en un test aislado sin restablecerlo en un hook afterEach provoca que la respuesta simulada se filtre a los tests sucesivos, desencadenando fallos colaterales en módulos independientes.
Solución: Invoque siempre server.resetHandlers() dentro de vitest.setup.ts.
El empleo de await page.waitForTimeout(2000) convierte la suite de pruebas en una fuente constante de Flaky Tests. En máquinas lentas de CI, dos segundos pueden resultar insuficientes (fallo en la prueba), mientras que en local penalizan el tiempo de respuesta.
Solución: Emplee siempre las aserciones con autoespera de Playwright: await expect(locator).toBeVisible().
Las Server Actions requieren APIs exclusivas de Node.js (criptografía, encabezados de red, controladores de base de datos). Evaluar estos tests en un entorno simulado de DOM (jsdom) genera errores de incompatibilidad con objetos globales.
Solución: Incorpore la directiva // @vitest-environment node en la primera línea de cada archivo de test de Server Actions.