Probar Server Components y Actions
Prueba Server Components async, Server Actions y Route Handlers tratándolos como funciones async normales.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
// Test an async Server Component as a function
import { render, screen } from "@testing-library/react";
// Server Components are async functions -- call and render their output
const Component = await ServerComponent({ params: { id: "1" } });
render(Component);
expect(screen.getByText("Alice")).toBeInTheDocument();
// Test a Server Action as a unit function
const formData = new FormData();
formData.set("email", "alice@example.com");
const result = await createUser(formData);
expect(result.success).toBe(true);
// Test a Route Handler as a function
const request = new Request("http://localhost/api/users", { method: "GET" });
const response = await GET(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toHaveLength(3);Cuándo usarlo: Cuando pruebas Server Components, Server Actions o Route Handlers del App Router de Next.js de forma aislada.
Ejemplo funcional
// src/lib/db.ts
export interface User {
id: string;
name: string;
email: string;
}
export async function getUserById(id: string): Promise<User | null> {
// In production, this queries a database
throw new Error("Not implemented -- use mock in tests");
}
export async function createUser(data: Omit<User, "id">): Promise<User> {
throw new Error("Not implemented -- use mock in tests");
}
export async function getAllUsers(): Promise<User[]> {
throw new Error("Not implemented -- use mock in tests");
}// src/app/users/[id]/page.tsx
import { getUserById } from "@/lib/db";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ id: string }>;
}
export default async function UserPage({ params }: Props) {
const { id } = await params;
const user = await getUserById(id);
if (!user) {
notFound();
}
return (
<article>
<h1>{user.name}</h1>
<p>{user.email}</p>
</article>
);
}// src/app/users/[id]/page.test.tsx
import { render, screen } from "@testing-library/react";
import { vi, describe, it, expect } from "vitest";
import UserPage from "./page";
// Mock the database module
vi.mock("@/lib/db", () => ({
getUserById: vi.fn(),
}));
// Mock next/navigation
const mockNotFound = vi.fn();
vi.mock("next/navigation", () => ({
notFound: () => {
mockNotFound();
throw new Error("NEXT_NOT_FOUND");
},
}));
import { getUserById } from "@/lib/db";
describe("UserPage", () => {
it("renders user data", async () => {
vi.mocked(getUserById).mockResolvedValue({
id: "1",
name: "Alice Johnson",
email: "alice@example.com",
});
// Async Server Components return JSX -- await then render
const jsx = await UserPage({ params: Promise.resolve({ id: "1" }) });
render(jsx);
expect(screen.getByRole("heading")).toHaveTextContent("Alice Johnson");
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(getUserById).toHaveBeenCalledWith("1");
});
it("calls notFound when user does not exist", async () => {
vi.mocked(getUserById).mockResolvedValue(null);
await expect(
UserPage({ params: Promise.resolve({ id: "999" }) })
).rejects.toThrow("NEXT_NOT_FOUND");
expect(mockNotFound).toHaveBeenCalled();
});
});// src/app/actions/user-actions.ts
"use server";
import { z } from "zod";
import { createUser as dbCreateUser } from "@/lib/db";
import { revalidatePath } from "next/cache";
const createUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
interface ActionState {
success: boolean;
message: string;
errors: Record<string, string>;
}
export async function createUserAction(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
};
const parsed = createUserSchema.safeParse(rawData);
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
errors[issue.path[0] as string] = issue.message;
}
return { success: false, message: "Validation failed", errors };
}
await dbCreateUser(parsed.data);
revalidatePath("/users");
return { success: true, message: "User created", errors: {} };
}// src/app/actions/user-actions.test.ts
import { vi, describe, it, expect, beforeEach } from "vitest";
import { createUserAction } from "./user-actions";
// Mock dependencies
vi.mock("@/lib/db", () => ({
createUser: vi.fn().mockResolvedValue({ id: "new-1", name: "Alice", email: "alice@test.com" }),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
import { createUser } from "@/lib/db";
import { revalidatePath } from "next/cache";
describe("createUserAction", () => {
const initialState = { success: false, message: "", errors: {} };
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a user with valid data", async () => {
const formData = new FormData();
formData.set("name", "Alice Johnson");
formData.set("email", "alice@example.com");
const result = await createUserAction(initialState, formData);
expect(result).toEqual({
success: true,
message: "User created",
errors: {},
});
expect(createUser).toHaveBeenCalledWith({
name: "Alice Johnson",
email: "alice@example.com",
});
expect(revalidatePath).toHaveBeenCalledWith("/users");
});
it("returns validation errors for invalid data", async () => {
const formData = new FormData();
formData.set("name", "A"); // too short
formData.set("email", "not-an-email");
const result = await createUserAction(initialState, formData);
expect(result.success).toBe(false);
expect(result.errors.name).toBeDefined();
expect(result.errors.email).toBeDefined();
expect(createUser).not.toHaveBeenCalled();
});
it("returns validation error for missing fields", async () => {
const formData = new FormData();
const result = await createUserAction(initialState, formData);
expect(result.success).toBe(false);
expect(Object.keys(result.errors).length).toBeGreaterThan(0);
});
});// src/app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAllUsers, createUser } from "@/lib/db";
export async function GET() {
const users = await getAllUsers();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.name || !body.email) {
return NextResponse.json(
{ error: "Name and email are required" },
{ status: 400 }
);
}
const user = await createUser(body);
return NextResponse.json(user, { status: 201 });
}// src/app/api/users/route.test.ts
import { vi, describe, it, expect, beforeEach } from "vitest";
import { GET, POST } from "./route";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
getAllUsers: vi.fn().mockResolvedValue([
{ id: "1", name: "Alice", email: "alice@test.com" },
{ id: "2", name: "Bob", email: "bob@test.com" },
]),
createUser: vi.fn().mockResolvedValue({
id: "3",
name: "Charlie",
email: "charlie@test.com",
}),
}));
describe("GET /api/users", () => {
it("returns all users", async () => {
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(2);
expect(data[0].name).toBe("Alice");
});
});
describe("POST /api/users", () => {
it("creates a user with valid data", async () => {
const request = new NextRequest("http://localhost/api/users", {
method: "POST",
body: JSON.stringify({ name: "Charlie", email: "charlie@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Charlie");
});
it("returns 400 for missing fields", async () => {
const request = new NextRequest("http://localhost/api/users", {
method: "POST",
body: JSON.stringify({ name: "Charlie" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});Lo que demuestra esto:
- Probar Server Components async llamándolos como funciones y renderizando el resultado
- Simular
notFound()denext/navigation - Probar Server Actions como funciones async normales con FormData
- Simular la base de datos y
revalidatePath - Probar Route Handlers construyendo objetos Request
Profundización
Cómo funciona
- Los Server Components son funciones async que devuelven JSX — puedes llamarlas directamente, hacer await del resultado y pasarlo a
render() - Las Server Actions son funciones async que aceptan
FormData— las llamas directamente con FormData de prueba - Los Route Handlers exportan funciones con nombre (
GET,POST, etc.) que aceptanRequesty devuelvenResponse— objetos estándar de la Web API notFound()de Next.js lanza internamente un error especial — simúlalo para lanzar un error que puedas capturar en los testsrevalidatePathyrevalidateTagson APIs de caché de Next.js que no tienen efecto en los tests — simúlalas para verificar que se llaman
Variaciones
Probar un Server Component con search params:
// src/app/users/page.tsx
interface Props {
searchParams: Promise<{ q?: string; page?: string }>;
}
export default async function UsersPage({ searchParams }: Props) {
const { q, page } = await searchParams;
// ...
}
// Test
const jsx = await UsersPage({
searchParams: Promise.resolve({ q: "alice", page: "2" }),
});
render(jsx);Probar Server Actions con transacciones de base de datos:
vi.mock("@/lib/db", () => ({
db: {
transaction: vi.fn((fn) => fn({
insert: vi.fn().mockResolvedValue([{ id: "1" }]),
update: vi.fn().mockResolvedValue([]),
})),
},
}));Notas de TypeScript
// Typing Server Component props for tests
import type { Metadata } from "next";
// Server Components can export generateMetadata too
export async function generateMetadata({ params }: Props): Promise<Metadata> {
// Test this as a regular async function
}
// NextRequest construction for route handler tests
const request = new NextRequest("http://localhost/api/users?page=2", {
method: "GET",
headers: { "Content-Type": "application/json" },
});Errores comunes
-
No puedes usar
render()con un componente async directamente —renderde React no admite componentes async. Solución: Haz await de la llamada a la función del Server Component y luego pasa el JSX resultante arender(). -
Directiva
"use server"en archivos de test — Vitest trata los archivos como módulos normales. La directiva"use server"se ignora. Solución: Esto está bien para las pruebas — las Server Actions son solo funciones async en el contexto de los tests. -
Simular
next/headers— Funciones comocookies()yheaders()solo funcionan en un contexto de servidor. Solución: Simula el módulo completo:vi.mock("next/headers", () => (\{ cookies: () => (\{ get: vi.fn().mockReturnValue(\{ value: "session-token" \}), set: vi.fn(), \}), headers: () => new Headers(\{ "x-user-id": "123" \}), \})); -
Los Server Components no se pueden probar con interactividad del lado del cliente — Solo puedes probar su salida renderizada, no clics ni cambios de state. Solución: Prueba la salida HTML/JSX renderizada. Prueba los componentes cliente interactivos por separado.
-
FormData en tests —
FormData.get()devuelveFormDataEntryValue(string o File), que puede necesitar casting en esquemas tipados. Solución: Zod maneja la coerción. En los tests,formData.set("key", "value")siempre establece strings.
Alternativas
| Alternativa | Usar cuando | No usar cuando |
|---|---|---|
| Playwright E2E | Necesitas probar Server Components con el renderizado completo de Next.js | Quieres tests unitarios rápidos |
| Storybook RSC | Quieres desarrollar y previsualizar Server Components de forma aislada | Necesitas tests basados en aserciones |
| Tests de integración | Quieres probar el ciclo completo de solicitud/respuesta | Los tests unitarios rápidos de funciones individuales son suficientes |
next/test (experimental) | Next.js publica utilidades oficiales de testing para RSC | La API aún no es estable |
FAQs
¿Cómo pruebo un Server Component async?
Llámalo como función, haz await del resultado y luego renderiza:
const jsx = await UserPage({ params: Promise.resolve({ id: "1" }) });
render(jsx);
expect(screen.getByText("Alice")).toBeInTheDocument();¿Por qué no puedo pasar un componente async directamente a render()?
render de React no admite componentes async. Debes hacer await de la llamada a la función del Server Component primero y luego pasar el JSX resultante a render().
¿Cómo pruebo una Server Action?
Trátala como una función async normal. Construye FormData, llama a la action y comprueba el resultado:
const formData = new FormData();
formData.set("email", "alice@example.com");
const result = await createUserAction(initialState, formData);
expect(result.success).toBe(true);¿Cómo simulo notFound() de next/navigation?
const mockNotFound = vi.fn();
vi.mock("next/navigation", () => ({
notFound: () => {
mockNotFound();
throw new Error("NEXT_NOT_FOUND");
},
}));Luego comprueba con expect(...).rejects.toThrow("NEXT_NOT_FOUND").
Trampa: ¿La directiva "use server" tiene significado en archivos de test?
No. Vitest trata los archivos como módulos normales, así que la directiva "use server" se ignora. Las Server Actions son solo funciones async en el contexto de los tests, lo cual está bien para las pruebas.
¿Cómo simulo next/headers (cookies y headers)?
vi.mock("next/headers", () => ({
cookies: () => ({
get: vi.fn().mockReturnValue({ value: "session-token" }),
set: vi.fn(),
}),
headers: () => new Headers({ "x-user-id": "123" }),
}));¿Cómo pruebo un Route Handler?
Construye un Request o NextRequest, llama a la función exportada y comprueba la respuesta:
const request = new NextRequest("http://localhost/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "a@b.com" }),
});
const response = await POST(request);
expect(response.status).toBe(201);¿Cómo verifico que se llamó a revalidatePath?
Simula next/cache y comprueba:
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
// ... call your server action
expect(revalidatePath).toHaveBeenCalledWith("/users");Trampa: ¿Puedo probar la interactividad de un Server Component (clics, state)?
No. Los Server Components solo producen salida HTML/JSX. Puedes probar la salida renderizada, pero no las interacciones del lado del cliente. Prueba los componentes cliente interactivos por separado.
¿Cómo pruebo un Server Component que recibe searchParams?
const jsx = await UsersPage({
searchParams: Promise.resolve({ q: "alice", page: "2" }),
});
render(jsx);¿Cómo tipar la construcción de NextRequest en TypeScript para tests de route handlers?
const request = new NextRequest("http://localhost/api/users?page=2", {
method: "GET",
headers: { "Content-Type": "application/json" },
});NextRequest extiende el Request estándar y está completamente tipado desde next/server.
¿Cómo simulo transacciones de base de datos en tests de Server Actions?
vi.mock("@/lib/db", () => ({
db: {
transaction: vi.fn((fn) => fn({
insert: vi.fn().mockResolvedValue([{ id: "1" }]),
update: vi.fn().mockResolvedValue([]),
})),
},
}));Relacionado
- Simulación en tests — simular módulos y APIs
- Probar comportamiento asíncrono — patrones async y MSW
- Probar formularios — probar formularios de Server Actions
- Estrategia de testing y buenas prácticas — cuándo usar tests unitarios vs E2E