Probar Server Components y Actions
Prueba Server Components async, Server Actions y Route Handlers tratándolos como funciones async normales.
Busca en todas las páginas de la documentación
Prueba Server Components async, Server Actions y Route Handlers tratándolos como funciones async normales.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
notFound() de next/navigationrevalidatePathrender()FormData - las llamas directamente con FormData de pruebaGET, POST, etc.) que aceptan Request y devuelven Response - objetos estándar de la Web APInotFound() de Next.js lanza internamente un error especial - simúlalo para lanzar un error que puedas capturar en los testsrevalidatePath y revalidateTag son APIs de caché de Next.js que no tienen efecto en los tests - simúlalas para verificar que se llamanProbar 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([]),
})),
},
}));// 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" },
});No puedes usar render() con un componente async directamente - render de 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 a render().
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 como cookies() y headers() 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() devuelve FormDataEntryValue (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.
| 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 |
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();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().
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);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").
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.
vi.mock("next/headers", () => ({
cookies: () => ({
get: vi.fn().mockReturnValue({ value: "session-token" }),
set: vi.fn(),
}),
headers: () => new Headers({ "x-user-id": "123" }),
}));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);Simula next/cache y comprueba:
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
// ... call your server action
expect(revalidatePath).toHaveBeenCalledWith("/users");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.
const jsx = await UsersPage({
searchParams: Promise.resolve({ q: "alice", page: "2" }),
});
render(jsx);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.
vi.mock("@/lib/db", () => ({
db: {
transaction: vi.fn((fn) => fn({
insert: vi.fn().mockResolvedValue([{ id: "1" }]),
update: vi.fn().mockResolvedValue([]),
})),
},
}));Revisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥