Pruebas en Next.js
Receta
Prueba React Server Components, Server Actions, Route Handlers y Client Components en un proyecto Next.js 15+ App Router usando Vitest y React Testing Library.
Ejemplo Funcional
Configuración de Vitest
// vitest.config.ts
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"],
include: ["**/*.test.{ts,tsx}"],
alias: {
"@": path.resolve(__dirname, "."),
},
},
});// vitest.setup.ts
import "@testing-library/jest-dom/vitest";// package.json (scripts)
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}Pruebas de un Client Component
// components/counter.tsx
"use client";
import { useState } from "react";
export function Counter({ initialCount = 0 }: { initialCount?: number }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p data-testid="count">Conteo: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Incrementar</button>
</div>
);
}// components/counter.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Counter } from "./counter";
describe("Counter", () => {
it("renderiza el conteo inicial", () => {
render(<Counter initialCount={5} />);
expect(screen.getByTestId("count")).toHaveTextContent("Conteo: 5");
});
it("incrementa al hacer clic", () => {
render(<Counter />);
fireEvent.click(screen.getByText("Incrementar"));
expect(screen.getByTestId("count")).toHaveTextContent("Conteo: 1");
});
});Pruebas de un Server Component (Asincronía)
// app/posts/post-list.tsx
import { db } from "@/lib/db";
export async function PostList() {
const posts = await db.post.findMany();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}// app/posts/post-list.test.tsx
import { describe, it, expect, vi } from "vitest";
vi.mock("@/lib/db", () => ({
db: {
post: {
findMany: vi.fn().mockResolvedValue([
{ id: "1", title: "First Post" },
{ id: "2", title: "Second Post" },
]),
},
},
}));
describe("PostList", () => {
it("renderiza posts de la base de datos", async () => {
// Importar después de mockear
const { PostList } = await import("./post-list");
// Espera el Server Component (es una función asincronía)
const jsx = await PostList();
// Usa render para probar el JSX devuelto
const { render } = await import("@testing-library/react");
const { getAllByRole } = render(jsx);
const items = getAllByRole("listitem");
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent("First Post");
});
});Pruebas de una Server Action
// app/actions.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
if (!title || title.length < 3) {
return { error: "El título debe tener al menos 3 caracteres" };
}
await db.post.create({ data: { title } });
revalidatePath("/posts");
return { success: true };
}// app/actions.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
db: {
post: {
create: vi.fn().mockResolvedValue({ id: "1", title: "Test" }),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
describe("createPost", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("devuelve error para título corto", async () => {
const { createPost } = await import("./actions");
const formData = new FormData();
formData.set("title", "ab");
const result = await createPost(formData);
expect(result).toEqual({ error: "El título debe tener al menos 3 caracteres" });
});
it("crea post y revalida", async () => {
const { createPost } = await import("./actions");
const { db } = await import("@/lib/db");
const { revalidatePath } = await import("next/cache");
const formData = new FormData();
formData.set("title", "Valid Title");
const result = await createPost(formData);
expect(result).toEqual({ success: true });
expect(db.post.create).toHaveBeenCalledWith({
data: { title: "Valid Title" },
});
expect(revalidatePath).toHaveBeenCalledWith("/posts");
});
});Pruebas de un Route Handler
// app/api/posts/route.test.ts
import { describe, it, expect, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
db: {
post: {
findMany: vi.fn().mockResolvedValue([{ id: "1", title: "Test" }]),
},
},
}));
describe("GET /api/posts", () => {
it("devuelve posts como JSON", async () => {
const { GET } = await import("./route");
const request = new NextRequest("http://localhost:3000/api/posts");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.posts).toHaveLength(1);
});
});Análisis Profundo
Cómo Funciona
- Server Components son funciones asincronías que devuelven JSX. Los pruebas llamando a la función directamente y renderizando el JSX devuelto, ya que no pueden usar hooks o APIs de navegador.
- Server Actions son funciones asincronías con la directiva
"use server". Elimina la directiva mockeando el límite del módulo y prueba los como funciones asincronías simples. vi.mock()debe ser llamado antes de importar el módulo bajo prueba. Usaimport()dinámico después devi.mock()para asegurar que los mocks se aplican.revalidatePathyrevalidateTagdenext/cachedeben ser mockeados para prevenir errores en tiempo de ejecución y para verificar que sean llamados correctamente.- Route Handlers pueden ser probados importando las funciones del método HTTP exportadas y pasando instancias de
NextRequest.
Variaciones
Pruebas de Integración con Playwright:
// e2e/posts.spec.ts
import { test, expect } from "@playwright/test";
test("crea un post", async ({ page }) => {
await page.goto("/posts/new");
await page.fill('[name="title"]', "My New Post");
await page.click('button[type="submit"]');
await expect(page.locator("text=My New Post")).toBeVisible();
});Pruebas con MSW (Mock Service Worker):
// mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/posts", () => {
return HttpResponse.json([
{ id: "1", title: "Mocked Post" },
]);
}),
];Notas de TypeScript
- Usa
vi.fn<Parameters<typeof fn>, ReturnType<typeof fn>>()para mocks completamente tipados. FormDataestá disponible globalmente en Vitest con el entornojsdom. No se necesita polyfill.- Importa
NextRequestdenext/serveren las pruebas. Funciona fuera del tiempo de ejecución de Next.js para construir objetos de solicitud.
Trampas
- La directiva
"use server"se ignora en entornos de prueba. Server Actions son solo funciones asincronías cuando se importan directamente. Está bien para pruebas unitarias pero significa que no estás probando el límite de serialización. cookies()yheaders()deben ser mockeados. Lanzan fuera del contexto de solicitud de Next.js. Mockea los convi.mock("next/headers").- React Testing Library renderiza sincronicamente. Para Server Components asincronios,
awaitla función del componente primero, luego pasa el resultado arender(). redirect()lanza en pruebas. Mockeanext/navigationo envuelve las llamadas en try/catch y aserta el error lanzado.- Vitest no transforma
next/imageonext/linkautomáticamente. Crea mocks manuales o usavi.mock()para stubear los.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Vitest + RTL | Rápido, moderno, nativo de ESM | Mock manual de Next.js requerido |
| Jest + RTL | Gran ecosistema, ampliamente documentado | Más lento, orientado a CJS, necesita transformación SWC |
| Playwright | Navegador completo, integración verdadera | Más lento, requiere servidor en ejecución |
| Cypress | Depuración visual, pruebas de componentes | Más pesado, no ideal para Server Components |
| next/experimental/testing | Utilidades de prueba oficiales de Next.js | Experimental, superficie de API limitada |
Preguntas Frecuentes
¿Cómo pruebas un Server Component asincronía con React Testing Library?
- Llama la función del componente directamente con
await(por ejemplo,const jsx = await PostList()). - Pasa el JSX devuelto a
render(). - Server Components son funciones asincronías, así que debes resolverlas antes de renderizar.
¿Por qué debe vi.mock() ser llamado antes de importar el módulo bajo prueba?
- Vitest eleva las llamadas a
vi.mock()a la parte superior del archivo, peroimport()dinámico no se eleva. - Si importas el módulo estáticamente antes de
vi.mock(), se usa el módulo real en lugar del mock. - Usa
await import("./module")después devi.mock()para asegurar que los mocks se aplican.
¿Cómo mockeas cookies() y headers() de next/headers en pruebas?
vi.mock("next/headers", () => ({
cookies: vi.fn().mockResolvedValue({
get: vi.fn().mockReturnValue({ value: "mock-token" }),
}),
headers: vi.fn().mockResolvedValue(new Headers()),
}));- Ambos lanzan fuera del contexto de solicitud de Next.js, así que deben ser mockeados.
¿Qué hace la directiva "use server" en un entorno de prueba?
- La directiva
"use server"se ignora cuando importas Server Actions directamente en pruebas. - Server Actions se comportan como funciones asincronías simples.
- Está bien para pruebas unitarias pero significa que el límite de serialización no se prueba.
¿Cómo pruebas un Route Handler?
- Importa la función del método HTTP exportada (por ejemplo,
GET,POST) del archivoroute.ts. - Crea un
new NextRequest("http://localhost:3000/api/endpoint")y pásalo a la función. - Aserta sobre el estado de la respuesta devuelto y el cuerpo JSON.
Trampa: ¿Por qué redirect() lanza un error en pruebas?
redirect()lanza un error especialNEXT_REDIRECTinternamente.- En pruebas, esto surge como una excepción sin capturar.
- Mockea
next/navigationconvi.mock()o envuelve la llamada en try/catch y aserta el error lanzado.
¿Por qué necesitas mockear revalidatePath y revalidateTag en pruebas de Server Action?
- Estas funciones de
next/cachedependen del contexto de tiempo de ejecución de Next.js. - Llamarlas fuera de ese contexto lanza un error en tiempo de ejecución.
- Mockearlas también te permite verificar que fueron llamadas con los argumentos correctos.
¿Cómo escribirías una función mock completamente tipada en TypeScript con Vitest?
import { vi } from "vitest";
import type { db } from "@/lib/db";
const mockCreate = vi.fn<
Parameters<typeof db.post.create>,
ReturnType<typeof db.post.create>
>();- Esto preserva los tipos de argumento y retorno de la función original.
¿Cuál es la diferencia entre Vitest y Jest para pruebas de Next.js?
- Vitest es nativo de ESM, más rápido y necesita menos configuración con proyectos modernos de Next.js.
- Jest es orientado a CJS, requiere transformaciones SWC, pero tiene un ecosistema más grande.
- Ambos funcionan; Vitest es la opción recomendada para nuevos proyectos de App Router.
Trampa: ¿Por qué Vitest no transforma automáticamente next/image o next/link?
- Vitest no ejecuta el compilador de Next.js o las transformaciones de Webpack/Turbopack.
- Componentes como
next/imagetienen internos complejos que fallan en un entorno jsdom simple. - Crea mocks manuales con
vi.mock("next/image", ...)para stubear los en pruebas.
¿Cuándo deberías usar Playwright en lugar de Vitest para pruebas?
- Usa Playwright para pruebas de integración end-to-end que necesitan un navegador real y servidor en ejecución.
- Usa Vitest para pruebas unitarias y de componentes que mockean dependencias y se ejecutan rápidamente.
- Playwright detecta problemas de renderizado reales pero es significativamente más lento.
¿Cómo manejas FormData en pruebas de Vitest?
const formData = new FormData();
formData.set("title", "My Post");
const result = await createPost(formData);FormDataestá disponible globalmente en Vitest con el entornojsdom.- No se necesita polyfill.
Relacionado
- Manejo de Errores - pruebas de límites de error y estados
- Route Handlers de API - los Route Handlers siendo probados
- Autenticación - mock de autenticación en pruebas
- Documentación de Vitest