Testing Server Components & Actions
Test async Server Components, Server Actions, and Route Handlers by treating them as plain async functions.
Recipe
Quick-reference recipe card -- copy-paste ready.
// 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);When to reach for this: When testing Next.js App Router Server Components, Server Actions, or Route Handlers in isolation.
Working Example
// 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);
});
});What this demonstrates:
- Testing async Server Components by calling them as functions and rendering the result
- Mocking
notFound()fromnext/navigation - Testing Server Actions as plain async functions with FormData
- Mocking database and
revalidatePath - Testing Route Handlers by constructing Request objects
Deep Dive
How It Works
- Server Components are async functions that return JSX -- you can call them directly, await the result, and pass it to
render() - Server Actions are async functions that accept
FormData-- you call them directly with test FormData - Route Handlers export named functions (
GET,POST, etc.) that acceptRequestand returnResponse-- standard Web API objects notFound()from Next.js throws a special error internally -- mock it to throw an error you can catch in testsrevalidatePathandrevalidateTagare Next.js caching APIs that have no effect in tests -- mock them to verify they are called
Variations
Testing a Server Component with 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);Testing Server Actions with database transactions:
vi.mock("@/lib/db", () => ({
db: {
transaction: vi.fn((fn) => fn({
insert: vi.fn().mockResolvedValue([{ id: "1" }]),
update: vi.fn().mockResolvedValue([]),
})),
},
}));TypeScript Notes
// 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" },
});Gotchas
-
Cannot
render()an async component directly -- React'srenderdoes not support async components. Fix: Await the Server Component function call, then pass the resulting JSX torender(). -
"use server"directive in test files -- Vitest treats files as normal modules. The"use server"directive is ignored. Fix: This is fine for testing -- Server Actions are just async functions in test context. -
Mocking
next/headers-- Functions likecookies()andheaders()only work in a server context. Fix: Mock the entire module:vi.mock("next/headers", () => (\{ cookies: () => (\{ get: vi.fn().mockReturnValue(\{ value: "session-token" \}), set: vi.fn(), \}), headers: () => new Headers(\{ "x-user-id": "123" \}), \})); -
Server Components cannot be tested with client-side interactivity -- You can only test their rendered output, not clicks or state changes. Fix: Test the rendered HTML/JSX output. Test interactive client components separately.
-
FormData in tests --
FormData.get()returnsFormDataEntryValue(string or File), which may need casting in typed schemas. Fix: Zod handles the coercion. In tests,formData.set("key", "value")always sets strings.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Playwright E2E | You need to test Server Components with full Next.js rendering | You want fast unit-level tests |
| Storybook RSC | You want to develop and preview Server Components in isolation | You need assertion-based testing |
| Integration tests | You want to test the full request/response cycle | Fast unit tests of individual functions are sufficient |
next/test (experimental) | Next.js releases official testing utilities for RSC | The API is not yet stable |
FAQs
How do I test an async Server Component?
Call it as a function, await the result, then render:
const jsx = await UserPage({ params: Promise.resolve({ id: "1" }) });
render(jsx);
expect(screen.getByText("Alice")).toBeInTheDocument();Why can't I pass an async component directly to render()?
React's render does not support async components. You must await the Server Component function call first, then pass the resulting JSX to render().
How do I test a Server Action?
Treat it as a plain async function. Construct FormData, call the action, and assert the result:
const formData = new FormData();
formData.set("email", "alice@example.com");
const result = await createUserAction(initialState, formData);
expect(result.success).toBe(true);How do I mock notFound() from next/navigation?
const mockNotFound = vi.fn();
vi.mock("next/navigation", () => ({
notFound: () => {
mockNotFound();
throw new Error("NEXT_NOT_FOUND");
},
}));Then assert with expect(...).rejects.toThrow("NEXT_NOT_FOUND").
Gotcha: Is the "use server" directive meaningful in test files?
No. Vitest treats files as normal modules, so the "use server" directive is ignored. Server Actions are just async functions in test context, which is fine for testing.
How do I mock next/headers (cookies and headers)?
vi.mock("next/headers", () => ({
cookies: () => ({
get: vi.fn().mockReturnValue({ value: "session-token" }),
set: vi.fn(),
}),
headers: () => new Headers({ "x-user-id": "123" }),
}));How do I test a Route Handler?
Construct a Request or NextRequest, call the exported function, and assert the response:
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);How do I verify that revalidatePath was called?
Mock next/cache and assert:
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
// ... call your server action
expect(revalidatePath).toHaveBeenCalledWith("/users");Gotcha: Can I test Server Component interactivity (clicks, state)?
No. Server Components only produce HTML/JSX output. You can test the rendered output, but not client-side interactions. Test interactive client components separately.
How do I test a Server Component that receives searchParams?
const jsx = await UsersPage({
searchParams: Promise.resolve({ q: "alice", page: "2" }),
});
render(jsx);How do I type NextRequest construction in TypeScript for route handler tests?
const request = new NextRequest("http://localhost/api/users?page=2", {
method: "GET",
headers: { "Content-Type": "application/json" },
});NextRequest extends the standard Request and is fully typed from next/server.
How do I mock database transactions in Server Action tests?
vi.mock("@/lib/db", () => ({
db: {
transaction: vi.fn((fn) => fn({
insert: vi.fn().mockResolvedValue([{ id: "1" }]),
update: vi.fn().mockResolvedValue([]),
})),
},
}));Related
- Mocking in Tests -- mocking modules and APIs
- Testing Async Behavior -- async patterns and MSW
- Testing Forms -- testing Server Action forms
- Testing Strategy & Best Practices -- when to unit vs E2E test