React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

testingvitestreact-testing-libraryserver-componentsserver-actionsintegration

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 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.
  • Route Handlers can be tested by importing the exported HTTP method functions and passing NextRequest instances.

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.
  • FormData is available globally in Vitest with jsdom environment. No polyfill needed.
  • Import NextRequest from next/server in tests. It works outside the Next.js runtime for constructing request objects.

Gotchas

  1. 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.
  2. cookies() and headers() must be mocked. They throw outside the Next.js request context. Mock them with vi.mock("next/headers").
  3. React Testing Library renders synchronously. For async Server Components, await the component function first, then pass the result to render().
  4. redirect() throws in tests. Mock next/navigation or wrap calls in try/catch and assert the thrown error.
  5. Vitest does not transform next/image or next/link automatically. Create manual mocks or use vi.mock() to stub them.

Alternatives

ApproachProsCons
Vitest + RTLFast, modern, ESM-nativeManual Next.js mocking required
Jest + RTLLarge ecosystem, widely documentedSlower, CJS-oriented, needs SWC transform
PlaywrightFull browser, true integrationSlower, requires running server
CypressVisual debugging, component testingHeavier, not ideal for Server Components
next/experimental/testingOfficial Next.js testing utilitiesExperimental, 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 dynamic import() 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") after vi.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 the route.ts file.
  • Create a new NextRequest("http://localhost:3000/api/endpoint") and pass it to the function.
  • Assert on the returned Response object's status and JSON body.
Gotcha: Why does redirect() throw an error in tests?
  • redirect() throws a special NEXT_REDIRECT error internally.
  • In tests, this surfaces as an uncaught exception.
  • Mock next/navigation with vi.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/cache rely 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/image have 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);
  • FormData is available globally in Vitest with the jsdom environment.
  • No polyfill is needed.