Testing Next.js Apps
Recipe
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.
Working Example
Vitest Configuration
// 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"
}
}Testing a 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">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");
});
});Testing a Server Component (Async)
// 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");
});
});Testing a 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: "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");
});
});Testing a 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("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);
});
});Deep Dive
How It Works
- Server Components are async functions that return JSX. You test them by calling the function directly and rendering the returned JSX, since they cannot use hooks or browser APIs.
- Server Actions are async functions with the
"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 dynamicimport()aftervi.mock()to ensure mocks are applied.revalidatePathandrevalidateTagfromnext/cacheshould be mocked to prevent runtime errors and to verify they are called correctly.- Route Handlers can be tested by importing the exported HTTP method functions and passing
NextRequestinstances.
Variations
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" },
]);
}),
];TypeScript Notes
- Use
vi.fn<Parameters<typeof fn>, ReturnType<typeof fn>>()for fully-typed mocks. FormDatais available globally in Vitest withjsdomenvironment. No polyfill needed.- Import
NextRequestfromnext/serverin tests. It works outside the Next.js runtime for constructing request objects.
Gotchas
- The
"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()andheaders()must be mocked. They throw outside the Next.js request context. Mock them withvi.mock("next/headers").- React Testing Library renders synchronously. For async Server Components,
awaitthe component function first, then pass the result torender(). redirect()throws in tests. Mocknext/navigationor wrap calls in try/catch and assert the thrown error.- Vitest does not transform
next/imageornext/linkautomatically. Create manual mocks or usevi.mock()to stub them.
Alternatives
| 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 |
FAQs
How do you test an async Server Component with React Testing Library?
- Call the component function directly with
await(e.g.,const jsx = await PostList()). - Pass the returned JSX to
render(). - Server Components are async functions, so you must resolve them before rendering.
Why must vi.mock() be called before importing the module under test?
- Vitest hoists
vi.mock()calls to the top of the file, but dynamicimport()is not hoisted. - If you import the module statically before
vi.mock(), the real module is used instead of the mock. - Use
await import("./module")aftervi.mock()to ensure mocks are applied.
How do you mock cookies() and headers() from next/headers in tests?
vi.mock("next/headers", () => ({
cookies: vi.fn().mockResolvedValue({
get: vi.fn().mockReturnValue({ value: "mock-token" }),
}),
headers: vi.fn().mockResolvedValue(new Headers()),
}));- Both throw outside the Next.js request context, so they must be mocked.
What does the "use server" directive do in a test environment?
- The
"use server"directive is ignored when you import Server Actions directly in tests. - Server Actions behave as plain async functions.
- This is fine for unit tests but means the serialization boundary is not tested.
How do you test a Route Handler?
- Import the exported HTTP method function (e.g.,
GET,POST) from theroute.tsfile. - Create a
new NextRequest("http://localhost:3000/api/endpoint")and pass it to the function. - Assert on the returned
Responseobject's status and JSON body.
Gotcha: Why does redirect() throw an error in tests?
redirect()throws a specialNEXT_REDIRECTerror internally.- In tests, this surfaces as an uncaught exception.
- Mock
next/navigationwithvi.mock()or wrap the call in try/catch and assert the thrown error.
Why do you need to mock revalidatePath and revalidateTag in Server Action tests?
- These functions from
next/cacherely on the Next.js runtime context. - Calling them outside that context throws a runtime error.
- Mocking them also lets you verify they were called with the correct arguments.
How would you write a fully-typed mock function in TypeScript with 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>
>();- This preserves argument and return types from the original function.
What is the difference between Vitest and Jest for Next.js testing?
- Vitest is ESM-native, faster, and needs less configuration with modern Next.js projects.
- Jest is CJS-oriented, requires SWC transforms, but has a larger ecosystem.
- Both work; Vitest is the recommended choice for new App Router projects.
Gotcha: Why doesn't Vitest automatically transform next/image or next/link?
- Vitest does not run the Next.js compiler or Webpack/Turbopack transformations.
- Components like
next/imagehave complex internals that fail in a plain jsdom environment. - Create manual mocks with
vi.mock("next/image", ...)to stub them in tests.
When should you use Playwright instead of Vitest for testing?
- Use Playwright for end-to-end integration tests that need a real browser and running server.
- Use Vitest for unit and component tests that mock dependencies and run fast.
- Playwright catches real rendering issues but is significantly slower.
How do you handle FormData in Vitest tests?
const formData = new FormData();
formData.set("title", "My Post");
const result = await createPost(formData);FormDatais available globally in Vitest with thejsdomenvironment.- No polyfill is needed.
Related
- Error Handling - testing error boundaries and states
- API Route Handlers - the Route Handlers being tested
- Authentication - mocking auth in tests
- Vitest Documentation