//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Test React Server Components, Server Actions, Route Handlers, and Client Components in a Next.js 15+ App Router project using Vitest and React Testing Library.
// 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"
}
}// 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">Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</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("renders initial count", () => {
render(<Counter initialCount={5} />);
expect(screen.getByTestId("count")).toHaveTextContent("Count: 5");
});
it("increments on click", () => {
render(<Counter />);
fireEvent.click(screen.getByText("Increment"));
expect(screen.getByTestId("count")).toHaveTextContent("Count: 1");
});
});// 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("renders posts from database", async () => {
// Import after mocking
const { PostList } = await import("./post-list");
// Await the Server Component (it's an async function)
const jsx = await PostList();
// Use render to test the returned JSX
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");
});
});// 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: "Title must be at least 3 characters" };
}
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("returns error for short title", async () => {
const { createPost } = await import("./actions");
const formData = new FormData();
formData.set("title", "ab");
const result = await createPost(formData);
expect(result).toEqual({ error: "Title must be at least 3 characters" });
});
it("creates post and revalidates", 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");
});
});// 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("returns posts as 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);
});
});"use server" directive. Strip the directive by mocking the module boundary and test them as plain async functions.vi.mock() must be called before importing the module under test. Use dynamic import() after vi.mock() to ensure mocks are applied.revalidatePath and revalidateTag from next/cache should be mocked to prevent runtime errors and to verify they are called correctly.NextRequest instances.Integration Testing with Playwright:
// e2e/posts.spec.ts
import { test, expect } from "@playwright/test";
test("creates a 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();
});Testing with 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" },
]);
}),
];vi.fn<Parameters<typeof fn>, ReturnType<typeof fn>>() for fully-typed mocks.FormData is available globally in Vitest with jsdom environment. No polyfill needed.NextRequest from next/server in tests. It works outside the Next.js runtime for constructing request objects."use server" directive is ignored in test environments. Server Actions are just async functions when imported directly. This is fine for unit tests but means you are not testing the serialization boundary.cookies() and headers() must be mocked. They throw outside the Next.js request context. Mock them with vi.mock("next/headers").await the component function first, then pass the result to render().redirect() throws in tests. Mock next/navigation or wrap calls in try/catch and assert the thrown error.next/image or next/link automatically. Create manual mocks or use vi.mock() to stub them.| Approach | Pros | Cons |
|---|---|---|
| Vitest + RTL | Fast, modern, ESM-native | Manual Next.js mocking required |
| Jest + RTL | Large ecosystem, widely documented | Slower, CJS-oriented, needs SWC transform |
| Playwright | Full browser, true integration | Slower, requires running server |
| Cypress | Visual debugging, component testing | Heavier, not ideal for Server Components |
| next/experimental/testing | Official Next.js testing utilities | Experimental, limited API surface |
await (e.g., const jsx = await PostList()).render().vi.mock() calls to the top of the file, but dynamic import() is not hoisted.vi.mock(), the real module is used instead of the mock.await import("./module") after vi.mock() to ensure mocks are applied.vi.mock("next/headers", () => ({
cookies: vi.fn().mockResolvedValue({
get: vi.fn().mockReturnValue({ value: "mock-token" }),
}),
headers: vi.fn().mockResolvedValue(new Headers()),
}));"use server" directive is ignored when you import Server Actions directly in tests.GET, POST) from the route.ts file.new NextRequest("http://localhost:3000/api/endpoint") and pass it to the function.Response object's status and JSON body.redirect() throws a special NEXT_REDIRECT error internally.next/navigation with vi.mock() or wrap the call in try/catch and assert the thrown error.next/cache rely on the Next.js runtime context.import { vi } from "vitest";
import type { db } from "@/lib/db";
const mockCreate = vi.fn<
Parameters<typeof db.post.create>,
ReturnType<typeof db.post.create>
>();next/image have complex internals that fail in a plain jsdom environment.vi.mock("next/image", ...) to stub them in tests.const formData = new FormData();
formData.set("title", "My Post");
const result = await createPost(formData);FormData is available globally in Vitest with the jsdom environment.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥