|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/testing
$cat docs/testing-next.js-applications.md
updated Last weekยท40 min readยทpublished

Testing Next.js Applications

โ—†Next.jsโ—†Testingโ—†Vitestโ—†Intermediate to Advanced๐ŸŽฏFree Tools
Introduction

Testing a Next.js application means covering multiple execution environments: Server Components render during the request, Client Components hydrate in the browser, route handlers process HTTP, and Server Actions mutate state across the network boundary. A strong strategy tests each layer with the right tool and the right isolation.

The testing pyramid still applies. Unit tests are fast and numerous: Server Components, Client Components, hooks, and utilities. Integration tests exercise route handlers and Server Actions against mocked dependencies. End-to-end tests with a real browser are slow but necessary for full user flows. This guide uses vitest, @testing-library/react, @testing-library/user-event, msw, and @playwright/test.

The goal is not 100% coverage for its own sake. The goal is confidence: the ability to refactor, upgrade dependencies, and ship features without breaking routing, data fetching, or auth. Tests that are slow or flaky will be ignored, so every example here prioritizes clarity, speed, and deterministic outcomes.

Testing Stack & Tooling

Vitest is the default recommendation for new Next.js projects. It supports ESM natively, has fast watch mode, and works well with Next.js path aliases. Jest is still viable for migrations, but Vitest's vi mocking utilities and ESM-first design fit the App Router better. React Testing Library is the standard for rendering, and user-event simulates realistic interactions.

LayerToolWhat it covers
Unit testsVitest + React Testing LibraryServer Components, Client Components, hooks, utilities
Route handlersnode-mocks-http or MSWGET/POST responses, error paths, auth
Server ActionsVitest with mocked auth/DBForm mutations, revalidation, redirects
API integrationMSWExternal service calls, retry logic, errors
E2EPlaywrightFull browser flows, visual regression
install.sh
Bash
1# Testing stack
2npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
3npm install -D node-mocks-http msw
4npm install -D @playwright/test
vitest.config.ts
TSX
1// vitest.config.ts
2import { defineConfig } from "vitest/config";
3import react from "@vitejs/plugin-react";
4import path from "path";
5
6export default defineConfig({
7 plugins: [react()],
8 test: {
9 environment: "jsdom",
10 globals: true,
11 setupFiles: ["./src/test/setup.ts"],
12 include: ["src/**/*.{test,spec}.{ts,tsx}"],
13 },
14 resolve: {
15 alias: {
16 "@": path.resolve(__dirname, "./src"),
17 },
18 },
19});
setup.ts
TSX
1// src/test/setup.ts
2import { expect } from "vitest";
3import * as matchers from "@testing-library/jest-dom/matchers";
4import { cleanup } from "@testing-library/react";
5
6expect.extend(matchers);
7
8afterEach(() => {
9 cleanup();
10 vi.clearAllMocks();
11});
โ„น

info

Set globals: true so you can use describe, it, and expect without importing them in every test file. Keep cleanup in afterEach so each test starts from a clean DOM.
Unit Testing Server Components

Server Components are async functions. Test them by awaiting the component and rendering the returned JSX. Mock Next.js imports with vi.mock hoisted to the top of the file. Target only the functions you need, such as next/headers or @/auth, and assert against the static output tree.

page.tsx
TSX
1// app/dashboard/page.tsx
2import { auth } from "@/auth";
3import { getProjects } from "@/lib/projects";
4
5export default async function DashboardPage() {
6 const session = await auth();
7 if (!session?.user) {
8 return <p data-testid="unauthorized">Please sign in.</p>;
9 }
10
11 const projects = await getProjects(session.user.id);
12
13 return (
14 <main>
15 <h1>Projects</h1>
16 <ul>
17 {projects.map((project) => (
18 <li key={project.id}>{project.name}</li>
19 ))}
20 </ul>
21 </main>
22 );
23}
page.test.tsx
TSX
1// app/dashboard/page.test.tsx
2import { describe, it, expect, vi } from "vitest";
3import { render } from "@testing-library/react";
4import DashboardPage from "./page";
5
6vi.mock("@/auth", () => ({ auth: vi.fn() }));
7vi.mock("@/lib/projects", () => ({ getProjects: vi.fn() }));
8
9import { auth } from "@/auth";
10import { getProjects } from "@/lib/projects";
11
12describe("DashboardPage", () => {
13 it("renders unauthenticated state", async () => {
14 vi.mocked(auth).mockResolvedValue(null);
15 const jsx = await DashboardPage();
16 const { getByTestId } = render(jsx);
17 expect(getByTestId("unauthorized")).toHaveTextContent("Please sign in.");
18 });
19
20 it("renders projects for an authenticated user", async () => {
21 vi.mocked(auth).mockResolvedValue({ user: { id: "u_1", name: "Ada" } });
22 vi.mocked(getProjects).mockResolvedValue([
23 { id: "p_1", name: "Forge" },
24 { id: "p_2", name: "Atlas" },
25 ]);
26
27 const jsx = await DashboardPage();
28 const { getByText } = render(jsx);
29
30 expect(getByText("Projects")).toBeInTheDocument();
31 expect(getByText("Forge")).toBeInTheDocument();
32 expect(getByText("Atlas")).toBeInTheDocument();
33 });
34});
โš 

warning

Server Components are not hydrated, so test them with static assertions. Do not use user-event or DOM interactions. Assert branching logic based on props, cookies, headers, or session state.
Unit Testing Client Components

Client Components run in jsdom. The pattern is render, query by accessible attributes, and assert behavior. Mock Next.js navigation hooks and custom hooks when the component depends on them. Return a minimal router object with only the properties the component uses.

SearchForm.tsx
TSX
1// components/SearchForm.tsx
2"use client";
3
4import { useState } from "react";
5import { useRouter } from "next/navigation";
6
7export default function SearchForm() {
8 const [query, setQuery] = useState("");
9 const router = useRouter();
10
11 const handleSubmit = (e: React.FormEvent) => {
12 e.preventDefault();
13 router.push(`/search?q=${encodeURIComponent(query)}`);
14 };
15
16 return (
17 <form onSubmit={handleSubmit}>
18 <label htmlFor="search">Search</label>
19 <input
20 id="search"
21 value={query}
22 onChange={(e) => setQuery(e.target.value)}
23 />
24 <button type="submit">Go</button>
25 </form>
26 );
27}
SearchForm.test.tsx
TSX
1// components/SearchForm.test.tsx
2import { describe, it, expect, vi } from "vitest";
3import { render, screen } from "@testing-library/react";
4import userEvent from "@testing-library/user-event";
5import SearchForm from "./SearchForm";
6
7const mockPush = vi.fn();
8
9vi.mock("next/navigation", () => ({
10 useRouter: () => ({
11 push: mockPush,
12 replace: vi.fn(),
13 refresh: vi.fn(),
14 back: vi.fn(),
15 forward: vi.fn(),
16 prefetch: vi.fn(),
17 }),
18 usePathname: () => "/",
19 useSearchParams: () => new URLSearchParams(),
20}));
21
22describe("SearchForm", () => {
23 it("navigates to search results on submit", async () => {
24 const user = userEvent.setup();
25 render(<SearchForm />);
26
27 const input = screen.getByLabelText("Search");
28 await user.type(input, "nextjs testing");
29 await user.click(screen.getByRole("button", { name: "Go" }));
30
31 expect(mockPush).toHaveBeenCalledWith("/search?q=nextjs%20testing");
32 });
33});
โœ“

best practice

Prefer screen.getByRole, getByLabelText, and getByText over getByTestId. Accessible queries make your tests more resilient and surface real usability problems.
Testing Route Handlers

Route handlers receive a Request and return a Response. The cleanest test strategy is to call the exported GET or POST function directly with a native Request and inspect the returned Response. Mock auth and data layers so tests stay fast and deterministic.

route.ts
TSX
1// app/api/projects/route.ts
2import { NextResponse } from "next/server";
3import { auth } from "@/auth";
4import { getProjects, createProject } from "@/lib/projects";
5
6export async function GET() {
7 const session = await auth();
8 if (!session?.user) {
9 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
10 }
11
12 const projects = await getProjects(session.user.id);
13 return NextResponse.json({ projects });
14}
15
16export async function POST(request: Request) {
17 const session = await auth();
18 if (!session?.user) {
19 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
20 }
21
22 const body = await request.json();
23 if (!body.name || typeof body.name !== "string") {
24 return NextResponse.json({ error: "Invalid name" }, { status: 400 });
25 }
26
27 const project = await createProject(session.user.id, body.name);
28 return NextResponse.json({ project }, { status: 201 });
29}
route.test.ts
TSX
1// app/api/projects/route.test.ts
2import { describe, it, expect, vi } from "vitest";
3import { GET, POST } from "./route";
4
5vi.mock("@/auth", () => ({ auth: vi.fn() }));
6vi.mock("@/lib/projects", () => ({
7 getProjects: vi.fn(),
8 createProject: vi.fn(),
9}));
10
11import { auth } from "@/auth";
12import { getProjects, createProject } from "@/lib/projects";
13
14describe("GET /api/projects", () => {
15 it("returns 401 when unauthenticated", async () => {
16 vi.mocked(auth).mockResolvedValue(null);
17 const response = await GET();
18 expect(response.status).toBe(401);
19 const body = await response.json();
20 expect(body.error).toBe("Unauthorized");
21 });
22
23 it("returns projects for the authenticated user", async () => {
24 vi.mocked(auth).mockResolvedValue({ user: { id: "u_1", name: "Ada" } });
25 vi.mocked(getProjects).mockResolvedValue([{ id: "p_1", name: "Forge" }]);
26
27 const response = await GET();
28 expect(response.status).toBe(200);
29
30 const body = await response.json();
31 expect(body.projects).toHaveLength(1);
32 expect(body.projects[0].name).toBe("Forge");
33 });
34});
35
36describe("POST /api/projects", () => {
37 it("rejects invalid input with 400", async () => {
38 vi.mocked(auth).mockResolvedValue({ user: { id: "u_1", name: "Ada" } });
39
40 const request = new Request("http://localhost:3000/api/projects", {
41 method: "POST",
42 body: JSON.stringify({ name: "" }),
43 });
44
45 const response = await POST(request);
46 expect(response.status).toBe(400);
47 });
48
49 it("creates a project and returns 201", async () => {
50 vi.mocked(auth).mockResolvedValue({ user: { id: "u_1", name: "Ada" } });
51 vi.mocked(createProject).mockResolvedValue({
52 id: "p_2",
53 name: "Atlas",
54 ownerId: "u_1",
55 });
56
57 const request = new Request("http://localhost:3000/api/projects", {
58 method: "POST",
59 body: JSON.stringify({ name: "Atlas" }),
60 });
61
62 const response = await POST(request);
63 expect(response.status).toBe(201);
64
65 const body = await response.json();
66 expect(body.project.name).toBe("Atlas");
67 });
68});
โ„น

info

For route handlers that call external APIs, prefer MSW over mocking fetch directly. MSW keeps your tests closer to real HTTP behavior and catches errors in header serialization, JSON parsing, and status handling.
Testing Server Actions

Server Actions run on the server in response to form submissions or direct invocation. In tests, import the action and call it directly with a FormData object or plain arguments. Mock auth, the database layer, and Next.js cache helpers such as revalidatePath. Assert both happy paths and error paths.

post.ts
TSX
1// app/actions/post.ts
2"use server";
3
4import { auth } from "@/auth";
5import { revalidatePath } from "next/cache";
6import { createPost } from "@/lib/posts";
7
8export async function submitPost(formData: FormData) {
9 const session = await auth();
10 if (!session?.user) {
11 throw new Error("Unauthorized");
12 }
13
14 const title = formData.get("title");
15 const content = formData.get("content");
16
17 if (typeof title !== "string" || typeof content !== "string") {
18 throw new Error("Invalid form data");
19 }
20
21 await createPost({ title, content, authorId: session.user.id });
22 revalidatePath("/posts");
23}
post.test.ts
TSX
1// app/actions/post.test.ts
2import { describe, it, expect, vi } from "vitest";
3import { submitPost } from "./post";
4
5vi.mock("@/auth", () => ({ auth: vi.fn() }));
6vi.mock("@/lib/posts", () => ({ createPost: vi.fn() }));
7vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
8
9import { auth } from "@/auth";
10import { createPost } from "@/lib/posts";
11import { revalidatePath } from "next/cache";
12
13describe("submitPost", () => {
14 it("throws when the user is not authenticated", async () => {
15 vi.mocked(auth).mockResolvedValue(null);
16
17 const formData = new FormData();
18 formData.set("title", "Hello");
19 formData.set("content", "World");
20
21 await expect(submitPost(formData)).rejects.toThrow("Unauthorized");
22 });
23
24 it("creates a post and revalidates the list", async () => {
25 vi.mocked(auth).mockResolvedValue({ user: { id: "u_1", name: "Ada" } });
26 vi.mocked(createPost).mockResolvedValue({ id: "post_1" });
27
28 const formData = new FormData();
29 formData.set("title", "Hello");
30 formData.set("content", "World");
31
32 await submitPost(formData);
33
34 expect(createPost).toHaveBeenCalledWith({
35 title: "Hello",
36 content: "World",
37 authorId: "u_1",
38 });
39 expect(revalidatePath).toHaveBeenCalledWith("/posts");
40 });
41});
โœ•

danger

Never rely on a use server action receiving a valid user ID from the client. Always derive the authenticated user from the session inside the action, and test that the action rejects requests when the session is missing.
Testing with MSW

Mock Service Worker intercepts requests at the network level in Node.js and the browser. It is the most robust way to mock external HTTP APIs because it does not require changing production code. Define handlers, start the server in a setup file, and reset handlers after each test. Use server.use() to override handlers for specific error-path tests.

handlers.ts
TSX
1// src/mocks/handlers.ts
2import { http, HttpResponse } from "msw";
3
4export const handlers = [
5 http.get("https://api.example.com/users/:id", ({ params }) => {
6 return HttpResponse.json({
7 id: params.id,
8 name: "Ada Lovelace",
9 email: "ada@example.com",
10 });
11 }),
12
13 http.post("https://api.example.com/users", async ({ request }) => {
14 const body = (await request.json()) as { name: string };
15
16 if (!body.name) {
17 return HttpResponse.json({ error: "Name required" }, { status: 400 });
18 }
19
20 return HttpResponse.json({ id: "u_99", name: body.name }, { status: 201 });
21 }),
22];
msw_example.tsx
TSX
1// src/test/setup.ts
2import { beforeAll, afterAll, afterEach } from "vitest";
3import { setupServer } from "msw/node";
4import { handlers } from "@/mocks/handlers";
5
6const server = setupServer(...handlers);
7
8beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
9afterEach(() => server.resetHandlers());
10afterAll(() => server.close());
11
12// lib/users.test.ts
13import { describe, it, expect } from "vitest";
14import { fetchUser } from "./users";
15import { server } from "@/mocks/server";
16import { http, HttpResponse } from "msw";
17
18describe("fetchUser", () => {
19 it("returns user data on success", async () => {
20 const user = await fetchUser("u_1");
21 expect(user.name).toBe("Ada Lovelace");
22 });
23
24 it("throws on a 500 response", async () => {
25 server.use(
26 http.get("https://api.example.com/users/u_1", () => {
27 return HttpResponse.json({ error: "Server down" }, { status: 500 });
28 })
29 );
30
31 await expect(fetchUser("u_1")).rejects.toThrow();
32 });
33});
๐Ÿ“

note

Set onUnhandledRequest: "error" so that any unmocked request fails the test immediately. This prevents tests from accidentally calling real external services and catches missing handlers as soon as they are introduced.
E2E Testing with Playwright

Playwright is the current standard for browser-based E2E testing. It runs full Chromium, Firefox, and WebKit, provides auto-waiting selectors, and supports tracing and screenshots. For Next.js, Playwright verifies that navigation, server-rendered content, client hydration, and form submissions all work together.

playwright.config.ts
TSX
1// playwright.config.ts
2import { defineConfig, devices } from "@playwright/test";
3
4export default defineConfig({
5 testDir: "./e2e",
6 fullyParallel: true,
7 forbidOnly: !!process.env.CI,
8 retries: process.env.CI ? 2 : 0,
9 workers: process.env.CI ? 1 : undefined,
10 reporter: "html",
11 use: {
12 baseURL: "http://localhost:3000",
13 trace: "on-first-retry",
14 screenshot: "only-on-failure",
15 },
16 projects: [
17 { name: "chromium", use: { ...devices["Desktop Chrome"] } },
18 { name: "firefox", use: { ...devices["Desktop Firefox"] } },
19 { name: "webkit", use: { ...devices["Desktop Safari"] } },
20 ],
21 webServer: {
22 command: "npm run dev",
23 url: "http://localhost:3000",
24 reuseExistingServer: !process.env.CI,
25 },
26});
e2e_examples.tsx
TSX
1// e2e/login.spec.ts
2import { test, expect } from "@playwright/test";
3import { LoginPage } from "./pages/LoginPage";
4
5test("user can log in", async ({ page }) => {
6 const login = new LoginPage(page);
7 await login.goto();
8 await login.fillEmail("ada@example.com");
9 await login.fillPassword("password123");
10 await login.submit();
11
12 await expect(page).toHaveURL("/dashboard");
13 await expect(page.locator("h1")).toHaveText("Dashboard");
14});
15
16// e2e/pages/LoginPage.ts
17import { Page } from "@playwright/test";
18
19export class LoginPage {
20 constructor(private page: Page) {}
21
22 async goto() {
23 await this.page.goto("/login");
24 }
25
26 async fillEmail(email: string) {
27 await this.page.fill('input[name="email"]', email);
28 }
29
30 async fillPassword(password: string) {
31 await this.page.fill('input[name="password"]', password);
32 }
33
34 async submit() {
35 await this.page.click('button[type="submit"]');
36 }
37
38 async expectError(message: string) {
39 await this.page.waitForSelector(`text=${message}`);
40 }
41}
โœ“

best practice

Keep E2E tests focused on critical user journeys. They should not replace unit tests. A good rule of thumb: write a unit test for every branch, an integration test for every handler or action, and an E2E test for every complete user flow.
Testing Auth Flows

Auth tests must cover both authenticated and unauthenticated states. At the unit level, mock the session helper and assert that protected components, route handlers, and Server Actions reject unauthenticated requests. At the E2E level, log in through the real auth flow or restore a saved storage state so the browser has a valid session cookie.

admin_tests.tsx
TSX
1// app/admin/page.tsx
2import { auth } from "@/auth";
3import { redirect } from "next/navigation";
4
5export default async function AdminPage() {
6 const session = await auth();
7
8 if (!session?.user || session.user.role !== "admin") {
9 redirect("/");
10 }
11
12 return <h1>Admin Dashboard</h1>;
13}
14
15// app/admin/page.test.tsx
16import { describe, it, expect, vi } from "vitest";
17import { render } from "@testing-library/react";
18import AdminPage from "./page";
19
20const redirect = vi.fn();
21
22vi.mock("@/auth", () => ({ auth: vi.fn() }));
23vi.mock("next/navigation", () => ({ redirect }));
24
25import { auth } from "@/auth";
26
27describe("AdminPage", () => {
28 it("redirects non-admin users", async () => {
29 vi.mocked(auth).mockResolvedValue({
30 user: { id: "u_1", name: "Ada", role: "member" },
31 });
32
33 await AdminPage();
34 expect(redirect).toHaveBeenCalledWith("/");
35 });
36
37 it("renders for admin users", async () => {
38 vi.mocked(auth).mockResolvedValue({
39 user: { id: "u_1", name: "Ada", role: "admin" },
40 });
41
42 const jsx = await AdminPage();
43 const { getByText } = render(jsx);
44
45 expect(getByText("Admin Dashboard")).toBeInTheDocument();
46 });
47});
โœ•

danger

Never commit real credentials or session tokens. Use test accounts, ephemeral databases, or credential mocks for auth setup. Store auth state files in .gitignore and generate them during CI.
Test Organization & Best Practices

Colocate unit and integration tests with the files they exercise. This makes it obvious when a feature is missing tests and encourages refactoring. E2E tests live in a dedicated e2e/ directory because they span multiple pages. Use consistent naming such as *.test.ts or *.spec.ts.

LocationUse case
Next to sourceUnit tests for components, hooks, utilities
__tests__ directoryTests for a folder of modules
e2e/ directoryBrowser-based user journeys
src/mocks/MSW handlers and shared mocks
src/test/Vitest setup and test utilities
package.json
JSON
1{
2 "scripts": {
3 "test": "vitest",
4 "test:ci": "vitest run --coverage",
5 "test:e2e": "playwright test",
6 "test:e2e:ui": "playwright test --ui"
7 }
8}

Coverage thresholds keep teams honest, but they can be gamed. Set thresholds on the lines and functions that matter, and review uncovered branches rather than chasing 100%. Flaky tests erode trust: reset mocks in afterEach, avoid shared mutable state, seed random values, and never rely on real network calls in unit tests.

๐Ÿ”ฅ

pro tip

If a test fails only occasionally, do not rerun it and hope. Isolate it, add logging, and replace implicit waits with explicit assertions. A flaky test is worse than no test because it trains the team to ignore CI failures.
Troubleshooting

The App Router, React 19, and async Server Components introduce new error messages. The most common issues are hydration mismatches, act warnings, unresolved async utilities, and leaked mocks. Fix hydration mismatches by aligning server and client mock state, and avoid time-based values that differ between environments.

SymptomLikely causeFix
Hydration mismatch warningServer and client mock state differAlign mocks, avoid Date.now
Warning: not wrapped in actState update after render without awaitUse waitFor or findBy*
Test passes in isolation but fails in suiteLeaked mock state from another testReset mocks in afterEach
Async function did not resolveServer Component not awaitedCall await Component() before rendering
MSW request not interceptedHandler URL mismatch or server not startedVerify exact URL, check beforeAll
Import not found in VitestESM or path alias not resolvedConfigure resolve.alias in vitest.config.ts
cleanup.ts
TSX
1import { afterEach, vi } from "vitest";
2
3afterEach(() => {
4 vi.clearAllMocks();
5 vi.unstubAllGlobals();
6});
๐Ÿ“

note

When a Server Component test fails with a React error, check that the component is being awaited before render. Async components return a promise of JSX, so render(await Component()) is the correct pattern in unit tests.
$Blueprint โ€” Engineering DocumentationยทSection ID: NEXT-TESTINGยทRevision: 1.0