Testing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later and TypeScript 5.1 or later. It covers the App Router and the Pages Router: the unit-test set-up is identical for both, and a short section shows what changes for getServerSideProps and pages/api. Caching only matters in one place, the instant() helper of Playwright, which exists only for the opt-in Cache Components model (cacheComponents: true); every other example is independent of the caching model, and the end-to-end tests of the Bookshelf were run against both a build of the previous model and a Cache Components build. Every example was built and run with Next.js 16.3.8, Node.js 24.16 and npm 11.13 on macOS, in a scratch copy of the Bookshelf (the catalogue of books and reviews that this section grows), against Vitest 5.0.3, Jest 30.5.2, React Testing Library 16.3.3, MSW 3.0.2, Playwright 1.63.0 and @next/playwright 16.3.8. Cypress is the one exception, explained in its own section. Each example is followed by the official page it derives from.

Next.js ships no test runner. The official documentation lists four tools — Cypress, Playwright, Vitest and Jest — and gives one set-up guide for each; this page walks through those guides, runs them against real Bookshelf code, and records what the guides do not tell you: which kinds of code each tool can reach, why server-only and next/navigation break a unit test, how an async Server Component can and cannot be tested, and why a mock library running in the test process cannot intercept what the Next.js server fetches. The testing fundamentals themselves (the test pyramid, Testing Library queries, user-event, act, MSW handlers, testing hooks) are not repeated; they are on React Testing. The browser-automation tools in depth (Selenium, Playwright and Cypress architecture, evidence capture, CI) are on E2E Testing in Real Browsers. The sibling pages of this section on authentication and on debugging and observability cover how to test signed-in flows and how to debug a failing test with the Node inspector.

What to Test, and With What

The official overview names five kinds of test — unit, component, integration, end-to-end and snapshot — and one constraint that shapes everything on this page: because async Server Components are new to the React ecosystem, some tools do not fully support them, so the documentation recommends end-to-end tests over unit tests for async components. The Vitest, Jest and Cypress guides each repeat the warning.

flowchart TD A["What do you want to test?"] --> B{"Which code is it?"} B -->|"Pure function or hook"| U["Vitest or Jest"] B -->|"Sync Server Component or Client Component"| C["Vitest or Jest with Testing Library, mock next/navigation"] B -->|"async Server Component or a whole page"| E["Playwright or Cypress end-to-end"] B -->|"Server Action or Route Handler"| F["Call the exported function, mock auth and next/cache"] F --> G["Add an HTTP or end-to-end test for the wire contract"] B -->|"Proxy"| H["Unit test the function and matcher, end-to-end for redirects"] B -->|"Instant navigation, Cache Components"| I["Playwright with instant()"]
Table 1. Which tool reaches which code
Code under test Tool Notes

Pure functions, hooks, utilities

Vitest or Jest

Fast and runner-agnostic. formatPrice below is an example.

Synchronous Server Components and Client Components

Vitest or Jest with React Testing Library

Rendered in jsdom, i.e. as a client tree. Anything that needs the App Router (useRouter, useSearchParams) must be mocked.

async Server Components and whole pages

Playwright or Cypress end-to-end

The official recommendation. A unit test can only call the component as a function (see below).

Server Actions

Vitest or Jest for the logic, an end-to-end or HTTP test for the contract

An action is a plain async function with a "use server" directive; the directive does nothing in a unit test.

Route Handlers

Vitest or Jest calling GET(new Request(…​)), request.get() of Playwright for the HTTP contract

The handlers are functions from Request to Response.

Proxy

Vitest or Jest for the function and its matcher, end-to-end for the redirect

next/experimental/testing/server helps (experimental).

Instant navigation (Cache Components)

Playwright with instant() from @next/playwright

Next.js 16.3 and later.

Source: Testing

The Scratch Project

The examples add tests to the Bookshelf from the earlier pages. The test files live beside the code, and each runner is pointed at its own folder so that Vitest and Jest can coexist in one project while you compare them.

Where the test files live (only the files added for this page)
app/
  [locale]/store/[slug]/page.tsx     (1)
  api/books/[id]/rating/route.ts     (2)
components/
  book-card.tsx                      (3)
  counter.tsx
  search-box.tsx
lib/
  format.ts
  ratings.ts
__tests__/
  vitest/                            (4)
  jest/                              (5)
e2e/
  bookshelf.spec.ts                  (6)
  instant.spec.ts
  fake-ratings.mjs
cypress/
  e2e/app.cy.ts                      (7)
  component/book-card.cy.tsx
  support/component.ts
  tsconfig.json
vitest.config.mts
vitest.setup.ts
jest.config.ts
jest.setup.ts
playwright.config.ts
cypress.config.ts
1 A product page used for the instant() example.
2 A Route Handler that calls a third-party ratings service.
3 Small components used by the unit tests: a synchronous component, a Client Component and one that uses the router.
4 Vitest tests.
5 Jest tests.
6 Playwright tests.
7 Cypress tests.

The two small pieces of production code that most tests touch are shown once here.

lib/format.ts and components/book-card.tsx
// lib/format.ts
export function formatPrice(cents: number, locale = "en-US", currency = "EUR"): string {
  if (!Number.isInteger(cents) || cents < 0) throw new RangeError("cents must be a non-negative integer");
  return new Intl.NumberFormat(locale, { style: "currency", currency }).format(cents / 100);
}

// components/book-card.tsx -- no hooks, no async: usable as a Server Component and inside a Client tree
import Link from "next/link";
import { formatPrice } from "@/lib/format";

export type BookCardProps = { id: string; title: string; author: string; priceCents: number };

export function BookCard({ id, title, author, priceCents }: BookCardProps) {
  return (
    <article>
      <h2>
        <Link href={`/en/books/${id}`}>{title}</Link>
      </h2>
      <p>{author}</p>
      <p>{formatPrice(priceCents)}</p>
    </article>
  );
}

The packages installed for this page, with the versions resolved on 2026-10-03:

Table 2. Dev dependencies used on this page
Package Version Role

vitest

5.0.3

Test runner for the unit and component tests. Needs Vite 6.4, 7 or 8 and, as a peer dependency, @types/node 22 or 24 and later

@vitejs/plugin-react

6.1.1

JSX transform for Vitest. Version 6 requires Vite 8

jsdom

30.1.1

DOM implementation for the jsdom test environment

jest, jest-environment-jsdom

30.5.2

The Jest alternative

@testing-library/react, @testing-library/dom, @testing-library/user-event, @testing-library/jest-dom

16.3.3, 10.4.2, 14.6.7, 7.0.1

Rendering, queries, interaction and matchers; @testing-library/dom is a peer dependency that has to be installed explicitly

msw

3.0.2

Network mocking in Node (requires Node.js 22.12 or later)

@playwright/test, @next/playwright

1.63.0, 16.3.8

End-to-end runner and the instant() helper

cypress

16.1.1 is current; 13.17.0 was run

See the Cypress section

@types/node

24.19.1

Raised from the scaffold’s ^20: Vitest 5 declares @types/node ^22.0.0 || >=24.0.0 as an optional peer, and npm refuses to resolve the tree with the scaffold’s version 20

Vitest

Vitest is a Vite-powered runner with a Jest-compatible API and native TypeScript and ESM support, which makes it the lowest-friction choice for a new Next.js project. Install it with the React plugin, jsdom and Testing Library:

Installing Vitest and Testing Library
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom \
  @testing-library/user-event @testing-library/jest-dom

Create vitest.config.mts (the .mts extension makes the file an ES module regardless of the "type" in package.json). The official page uses the vite-tsconfig-paths plugin to resolve the @/* alias. Vitest 5 runs on Vite 8, which resolves tsconfig.json paths itself, and prints a message asking you to drop the plugin; the config below uses the built-in option and was run as written.

vitest.config.mts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  resolve: { tsconfigPaths: true },
  test: {
    environment: "jsdom",
    include: ["__tests__/vitest/**/*.test.{ts,tsx}"],
    setupFiles: ["./vitest.setup.ts"],
  },
});
vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";

// "server-only" throws when imported outside a React Server environment; neutralise it in unit tests.
vi.mock("server-only", () => ({}));

afterEach(() => cleanup());

Two lines of the set-up deserve a comment. @testing-library/jest-dom/vitest registers the DOM matchers (toBeInTheDocument, toHaveAttribute) on Vitest’s expect. And the vi.mock("server-only") line is not in the official guide but is needed as soon as a test imports a module marked with import "server-only", such as the Bookshelf’s lib/books.ts: the package throws on purpose when it is loaded outside a Server Component environment. Without the mock, the import fails with This module cannot be imported from a Client Component module. It should only be used from a Server Component.

Add the script and run the suite once (vitest without run stays in watch mode, which is the official default):

package.json scripts
{
  "scripts": {
    "test": "vitest run",
    "test:jest": "jest",
    "test:e2e": "playwright test"
  }
}

A plain unit test and two component tests (a synchronous component and a Client Component with state):

tests/vitest/format.test.ts
import { describe, expect, test } from "vitest";
import { formatPrice } from "@/lib/format";

describe("formatPrice", () => {
  test("formats cents as a currency string", () => {
    expect(formatPrice(999)).toBe("€9.99");
    expect(formatPrice(799, "es-ES")).toContain("7,99");
  });

  test("rejects negative or fractional cents", () => {
    expect(() => formatPrice(-1)).toThrow(RangeError);
    expect(() => formatPrice(1.5)).toThrow(RangeError);
  });
});
components/counter.tsx and tests/vitest/components.test.tsx
// components/counter.tsx
"use client";

import { useState } from "react";

export function Counter({ label = "Copies" }: { label?: string }) {
  const [count, setCount] = useState(1);
  return (
    <div>
      <span>
        {label}: {count}
      </span>
      <button onClick={() => setCount((c) => c + 1)}>Add copy</button>
    </div>
  );
}

// __tests__/vitest/components.test.tsx
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BookCard } from "@/components/book-card";
import { Counter } from "@/components/counter";

test("BookCard renders title, author, price and a link", () => {
  render(<BookCard id="1" title="Dune" author="Frank Herbert" priceCents={999} />);
  expect(screen.getByRole("heading", { level: 2, name: "Dune" })).toBeInTheDocument();
  expect(screen.getByRole("link", { name: "Dune" })).toHaveAttribute("href", "/en/books/1");
  expect(screen.getByText("€9.99")).toBeInTheDocument();
});

test("Counter increments on click", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  expect(screen.getByText("Copies: 1")).toBeInTheDocument();
  await user.click(screen.getByRole("button", { name: "Add copy" }));
  expect(screen.getByText("Copies: 2")).toBeInTheDocument();
});
Running the Vitest suite of this page (final run, 11 files)
$ npm test

 RUN  v5.0.3 /path/to/bookshelf

 Test Files  11 passed (11)
      Tests  23 passed (23)
   Duration  1.09s

Jest with next/jest

Jest needs a transform to understand TypeScript and JSX. next/jest provides it: it configures Jest with the Next.js compiler (SWC), so no .babelrc is needed. The official page lists what the helper sets up for you:

  • a transform that uses the Next.js Compiler

  • automatic mocks for stylesheets (.css, .module.css and their Sass variants), image imports and next/font

  • loading of .env files (and their variants) into process.env

  • ignoring node_modules and .next for module resolution, and node_modules for transforms

  • loading of next.config.js for the flags that enable SWC transforms

Install Jest and the Testing Library packages, then write the config with the helper:

Installing Jest
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/dom \
  @testing-library/jest-dom @testing-library/user-event ts-node @types/jest

ts-node is what lets Jest read a jest.config.ts. The config below is the official one, with testMatch pointing at this page’s folder and the ESM-only package handling that the MSW section explains.

jest.config.ts
import type { Config } from "jest";
import nextJest from "next/jest.js";

const createJestConfig = nextJest({
  // Path to the Next.js app: loads next.config.ts and .env files into the test environment
  dir: "./",
});

const config: Config = {
  coverageProvider: "v8",
  testEnvironment: "jsdom",
  testMatch: ["<rootDir>/__tests__/jest/**/*.test.{ts,tsx}"],
  setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
};

// ESM-only packages (msw 3 and its dependencies) must be transformed by the Next.js compiler too.
// next/jest returns an async config function, so wrap it and adjust transformIgnorePatterns.
const esmOnly = ["msw", "@msw", "@mswjs", "rettime", "cookie", "until-async", "headers-polyfill", "@open-draft"];

export default async function jestConfig() {
  const resolved = await createJestConfig(config)();
  return {
    ...resolved,
    transformIgnorePatterns: [
      `/node_modules/(?!(${esmOnly.join("|")})/)`,
      "^.+\\.module\\.(css|sass|scss)$",
    ],
  };
}
jest.setup.ts
import "@testing-library/jest-dom";

// "server-only" throws outside a React Server environment.
jest.mock("server-only", () => ({}));

The official page tells you to map the @/* alias with moduleNameMapper. With paths declared in tsconfig.json, next/jest 16.3.8 resolved the alias without it in this project, so the mapper is omitted above; add it if your aliases are not in tsconfig.json or jsconfig.json. The @testing-library/jest-dom import is the form for version 6 and later (extend-expect was removed in 6.0).

tests/jest/book-card.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BookCard } from "@/components/book-card";
import { Counter } from "@/components/counter";

describe("BookCard", () => {
  it("renders a heading, a link and the formatted price", () => {
    render(<BookCard id="2" title="Neuromancer" author="William Gibson" priceCents={899} />);
    expect(screen.getByRole("heading", { level: 2, name: "Neuromancer" })).toBeInTheDocument();
    expect(screen.getByRole("link", { name: "Neuromancer" })).toHaveAttribute("href", "/en/books/2");
    expect(screen.getByText("€8.99")).toBeInTheDocument();
  });

  it("matches its snapshot", () => {
    const { container } = render(<BookCard id="3" title="Solaris" author="Stanislaw Lem" priceCents={799} />);
    expect(container).toMatchSnapshot();
  });
});

describe("Counter", () => {
  it("increments", async () => {
    const user = userEvent.setup();
    render(<Counter />);
    await user.click(screen.getByRole("button", { name: "Add copy" }));
    expect(screen.getByText("Copies: 2")).toBeInTheDocument();
  });
});
The snapshot Jest wrote on the first run (tests/jest/snapshots/book-card.test.tsx.snap)
exports[`BookCard matches its snapshot 1`] = `
<div>
  <article>
    <h2>
      <a
        href="/en/books/3"
      >
        Solaris
      </a>
    </h2>
    <p>
      Stanislaw Lem
    </p>
    <p>
      €7.99
    </p>
  </article>
</div>
`;
Running Jest (final run, 4 suites)
$ npm run test:jest

Test Suites: 4 passed, 4 total
Tests:       6 passed, 6 total
Snapshots:   1 passed, 1 total

Commit snapshots, review their diffs like code, and keep them small: a snapshot of a whole page fails for every unrelated change and trains people to press "update". Vitest has the same feature (toMatchSnapshot, toMatchInlineSnapshot).

Vitest or Jest

Both run the same Testing Library code and the same test shapes; the choice is about friction and about what is already in the repository.

Table 3. Vitest and Jest for a Next.js project
Vitest Jest with next/jest

Transform

The transform pipeline of Vite, plus @vitejs/plugin-react for JSX

Next.js compiler (SWC) through next/jest; no Babel

Config

vitest.config.mts, ESM and TypeScript out of the box; @/* through resolve.tsconfigPaths

jest.config.ts needs ts-node; @/* resolved from tsconfig.json in this project

ESM-only dependencies

Work without configuration (MSW 3 needed nothing)

Must be listed in transformIgnorePatterns, found by trial (MSW 3 and its dependencies)

Globals

Explicit imports (import { test } from "vitest") unless globals: true

describe, it, expect, jest are global

Env files

Call loadEnvConfig yourself (see below)

Loaded by next/jest

Async Server Components

Not supported by the tool (official page)

Not supported by the tool (official page)

Typical use

New projects, Vite-based monorepos

Existing Jest suites; projects that want the Next.js compiler to transform the tests

Do not run both on the same files; if you migrate, move folder by folder as this page’s tests/vitest and tests/jest do.

Testing Components: Client, Synchronous Server and async Server

A Client Component is a normal React component that Testing Library renders in jsdom. What breaks the test is not React but the framework APIs it calls. A component that uses useRouter or useSearchParams from next/navigation throws invariant expected app router to be mounted when rendered outside the App Router. Mock the module and assert on the mock:

components/search-box.tsx and its test
// components/search-box.tsx
"use client";

import { useRouter, useSearchParams } from "next/navigation";

export function SearchBox() {
  const router = useRouter();
  const params = useSearchParams();
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const q = new FormData(e.currentTarget).get("q");
        router.push(`/en?q=${encodeURIComponent(String(q))}`);
      }}
    >
      <input name="q" aria-label="Search" defaultValue={params?.get("q") ?? ""} />
      <button>Go</button>
    </form>
  );
}

// __tests__/vitest/search-box.test.tsx
import { expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchBox } from "@/components/search-box";

const push = vi.fn();
vi.mock("next/navigation", () => ({
  useRouter: () => ({ push }),
  useSearchParams: () => new URLSearchParams("q=dune"),
}));

test("SearchBox pushes the encoded query to the router", async () => {
  const user = userEvent.setup();
  render(<SearchBox />);
  const input = screen.getByRole("textbox", { name: "Search" });
  expect(input).toHaveValue("dune");
  await user.clear(input);
  await user.type(input, "war & peace");
  await user.click(screen.getByRole("button", { name: "Go" }));
  expect(push).toHaveBeenCalledWith("/en?q=war%20%26%20peace");
});

The params?.get with a question mark is not decoration: useSearchParams is typed as possibly null, because the same hook also serves Pages Router components. The Pages Router counterpart, useRouter from next/router, fails without a mock with NextRouter was not mounted.

A synchronous Server Component (no async, no cookies(), no await) is just a function that returns elements, so it renders in jsdom like any component: BookCard above is one. What you cannot do in a unit test is rely on the server-only machinery around it: import "server-only" modules need the mock shown earlier, and request APIs fail outside a request. In Vitest, calling cookies() from next/headers throws cookies was called outside a request scope (with a pointer to the next-dynamic-api-wrong-context message) and revalidatePath throws Invariant: static generation store missing. Both are the cue to move that code behind a function you can mock, or to test it end to end.

The Limits for async Server Components

The Bookshelf’s BookDetail is an async Server Component: it awaits the data layer and renders a Client Component (the review form). The official position is that Vitest, Jest and Cypress component tests do not support such components and that an end-to-end test should cover them. Two tests show exactly what that means.

tests/vitest/async-component.test.tsx
import { expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { BookDetail } from "@/app/[locale]/books/[id]/book-detail";

// The Client Component child talks to a Server Action: replace it, we only test the page here.
vi.mock("@/app/[locale]/actions", () => ({ submitReview: vi.fn() }));

test("async Server Component: call it as a function, then render the result", async () => {
  const ui = await BookDetail({ id: "1", label: "Send" }); // resolves to plain React elements
  render(ui);
  expect(screen.getByRole("heading", { level: 1, name: "Dune" })).toBeInTheDocument();
  expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument();
});

test("rendering the async function directly renders nothing (jsdom is a client environment)", () => {
  const messages: string[] = [];
  vi.spyOn(console, "error").mockImplementation((...args) => {
    messages.push(String(args[0]));
  });
  const { container } = render(<BookDetail id="1" label="Send" />);
  expect(container.innerHTML).toBe("");
  expect(messages.some((m) => m.includes("is an async Client Component"))).toBe(true);
  vi.restoreAllMocks();
});
  • Rendering the component directly fails. render(<BookDetail />) produces an empty container, and React logs that the component "is an async Client Component. Only Server Components can be async at the moment", followed by a warning that it suspended inside an act scope. jsdom plus Testing Library is a client renderer; it has no server renderer to resolve the promise.

  • Calling the component as a function works for a leaf. await BookDetail({ …​ }) returns the element tree, and rendering that tree is an ordinary component test. The Client Component child is rendered as a client component; its Server Action import has to be mocked, as above.

  • The workaround stops where the tree stops being plain. It resolves only the outermost component. A child that is itself async is still a promise-returning function in the tree and renders nothing; a page that awaits params or reads cookies() or headers() needs a request scope that a unit test does not have; Suspense boundaries and streaming are not exercised at all. For anything beyond a leaf, write the end-to-end test.

The rule of thumb: extract the logic of an async component into a plain function or into the data layer and unit-test that; keep the component thin, and cover it with a browser test. The same split is what makes the component easy to move between caching models, because neither 'use cache' nor <Suspense> placement shows up in the unit test.

Testing Server Actions

A Server Action is an async function in a module with "use server". In a unit test the directive is inert: you import the function and call it. What you must replace are the framework and session dependencies. The Bookshelf’s submitReview checks the session, validates the input with Zod, stores the review and revalidates the page, so the test mocks auth and revalidatePath and drives it with a FormData:

tests/vitest/server-action.test.ts
import { beforeEach, describe, expect, test, vi } from "vitest";

const auth = vi.fn();
vi.mock("@/auth", () => ({ auth: () => auth() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));

import { revalidatePath } from "next/cache";
import { submitReview } from "@/app/[locale]/actions";
import { listReviews } from "@/lib/books";

function form(fields: Record<string, string>) {
  const fd = new FormData();
  for (const [k, v] of Object.entries(fields)) fd.set(k, v);
  return fd;
}

describe("submitReview", () => {
  beforeEach(() => vi.clearAllMocks());

  test("rejects anonymous callers", async () => {
    auth.mockResolvedValue(null);
    const res = await submitReview({ ok: false }, form({ bookId: "1", rating: "5", text: "Great" }));
    expect(res).toEqual({ ok: false, error: "Sign in required" });
    expect(revalidatePath).not.toHaveBeenCalled();
  });

  test("validates input on the server", async () => {
    auth.mockResolvedValue({ user: { name: "Test User" } });
    const res = await submitReview({ ok: false }, form({ bookId: "1", rating: "9", text: "Too high" }));
    expect(res).toEqual({ ok: false, error: "Invalid input" });
  });

  test("stores the review and revalidates the page", async () => {
    auth.mockResolvedValue({ user: { name: "Test User" } });
    const res = await submitReview({ ok: false }, form({ bookId: "2", rating: "4", text: "Cyberpunk classic" }));
    expect(res).toEqual({ ok: true });
    expect(revalidatePath).toHaveBeenCalledWith("/[locale]/books/[id]", "page");
    expect((await listReviews("2")).map((r) => r.text)).toEqual(["Cyberpunk classic"]);
  });
});

Notes on the shape of the test:

  • The first test is the one that matters for security: an action is a public HTTP endpoint, so the negative cases (no session, invalid input, and in a real application the wrong owner) deserve as much attention as the happy path. The rule is explained on Server Actions and Mutations.

  • vi.mock calls are hoisted above the imports by Vitest; the auth function is defined before them only because the mock factory refers to it lazily.

  • A unit test proves the logic, not the wire format. An action posted by a real form travels as a multipart/form-data request with an action ID. The end-to-end test below submits the real form in a browser as an anonymous visitor and asserts on the visible error, which exercises the compiled action, the form binding and useActionState together. Signed-in flows (a session cookie in the browser context, saved with Playwright’s storage state) are covered on the authentication page of this section.

Testing Route Handlers

A Route Handler is an exported function per HTTP method that takes a Request and returns a Response; nothing else is needed to unit test it. Call it with a new Request(url):

tests/vitest/route-handler.test.ts
import { expect, test } from "vitest";
import { GET } from "@/app/api/books/route";

test("GET /api/books filters by the q search parameter", async () => {
  const res = await GET(new Request("http://localhost/api/books?q=dune"));
  expect(res.status).toBe(200);
  expect(res.headers.get("content-type")).toContain("application/json");
  const body = (await res.json()) as { id: string; title: string }[];
  expect(body.map((b) => b.title)).toEqual(["Dune"]);
});

The same test under Jest needs one extra line: Jest’s default jsdom environment has no Request or Response, so the file is switched to the Node environment with a docblock:

tests/jest/route-handler.test.ts
/**
 * @jest-environment node
 */
import { GET } from "@/app/api/books/route";

describe("GET /api/books", () => {
  it("returns JSON filtered by q", async () => {
    const res = await GET(new Request("http://localhost/api/books?q=gibson"));
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual([
      { id: "2", title: "Neuromancer", author: "William Gibson", priceCents: 899 },
    ]);
  });
});

For a dynamic Route Handler, the second argument carries the route parameters, and since Next.js 15 they are a Promise. Build the context accordingly; the RouteContext type is the global helper that Next.js generates, the same one the handler uses:

app/api/books/[id]/rating/route.ts
import { getRating } from "@/lib/ratings";

export async function GET(_request: Request, ctx: RouteContext<"/api/books/[id]/rating">) {
  const { id } = await ctx.params;
  try {
    const rating = await getRating(id);
    if (!rating) return Response.json({ error: "No rating" }, { status: 404 });
    return Response.json(rating);
  } catch {
    return Response.json({ error: "Ratings service unavailable" }, { status: 502 });
  }
}

The unit test for it appears in the next section, together with the network mock it needs. When the handler reads cookies or the parsed URL, build a NextRequest from next/server instead of a bare Request. For the HTTP contract, test the running application: Playwright’s request fixture, shown below, sends real requests to the production build and needs no browser. See Route Handlers and the Backend for Frontend for what the handlers do.

Source: Route Handlers

Testing the Proxy

The proxy function (the middleware file renamed in Next.js 16) is a function from NextRequest to NextResponse, so the logic is testable directly. Next.js 15.1 added experimental helpers in next/experimental/testing/server: one that tells whether the proxy would run for a URL given its matcher config, and getRedirectUrl, getRewrittenUrl and isRewrite to read the response.

tests/vitest/proxy.test.ts
import { expect, test } from "vitest";
import { NextRequest } from "next/server";
import * as testing from "next/experimental/testing/server";
import { config, proxy } from "@/proxy";

test("the matcher skips API routes and files", () => {
  const match = (url: string) => testing.unstable_doesMiddlewareMatch({ config, url });
  expect(match("/books")).toBe(true);
  expect(match("/api/books")).toBe(false);
  expect(match("/favicon.ico")).toBe(false);
});

test("proxy redirects to the preferred locale", async () => {
  const req = new NextRequest("http://localhost:3000/books", { headers: { "accept-language": "es-ES,es;q=0.9" } });
  const res = proxy(req);
  expect(testing.getRedirectUrl(res)).toBe("http://localhost:3000/es/books");
});

test("proxy lets localized paths through", () => {
  const res = proxy(new NextRequest("http://localhost:3000/en/books"));
  expect(res.headers.get("x-middleware-next")).toBe("1");
});

One detail differs between the documentation and the package. The proxy reference documents the matcher helper as unstable_doesProxyMatch. In next@16.3.8 the module next/experimental/testing/server exports getRedirectUrl, getRewrittenUrl, isRewrite, unstable_doesMiddlewareMatch and unstable_getResponseFromNextConfig; there is no unstable_doesProxyMatch, so the example imports the old name. Both names are unstable_, so pin the version and re-check on upgrade. The last test reads the internal x-middleware-next header, which is an implementation detail of the same kind. The redirect itself is also asserted end to end below. See Redirects, Rewrites and the Proxy for the proxy.

Mocking the Network with MSW

Mock Service Worker intercepts requests at the network level, so the code under test keeps calling fetch and nothing in it is replaced. lib/ratings.ts calls a third-party ratings service (the base URL comes from configuration, not from user input), and the tests answer for the service:

lib/ratings.ts
export type Rating = { bookId: string; average: number; count: number };

// Calls a third-party service; the base URL comes from configuration, not from user input.
const BASE_URL = process.env.RATINGS_API_URL ?? "https://ratings.example.test";

export async function getRating(bookId: string): Promise<Rating | null> {
  const res = await fetch(`${BASE_URL}/api/ratings/${encodeURIComponent(bookId)}`);
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`ratings service failed: ${res.status}`);
  return (await res.json()) as Rating;
}
tests/vitest/ratings-msw.test.ts
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { getRating } from "@/lib/ratings";

const server = setupServer(
  http.get("https://ratings.example.test/api/ratings/:id", ({ params }) =>
    params.id === "1"
      ? HttpResponse.json({ bookId: "1", average: 4.5, count: 12 })
      : new HttpResponse(null, { status: 404 }),
  ),
);

beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test("returns the rating from the third-party service", async () => {
  expect(await getRating("1")).toEqual({ bookId: "1", average: 4.5, count: 12 });
});

test("returns null on 404", async () => {
  expect(await getRating("99")).toBeNull();
});

test("throws on a server error (per-test override)", async () => {
  server.use(http.get("https://ratings.example.test/api/ratings/:id", () => new HttpResponse(null, { status: 500 })));
  await expect(getRating("1")).rejects.toThrow("ratings service failed: 500");
});

test("a request without a handler fails the test instead of reaching the network", async () => {
  await expect(fetch("https://unmocked.example.test/ping")).rejects.toThrow();
});

The last test shows the reason for onUnhandledFrame: "error": a request without a handler is reported as [MSW] Error: intercepted a request without a matching request handler and rejects, so a forgotten mock cannot silently hit the real service. MSW 3 renamed the option that MSW 2 called onUnhandledRequest, because it also covers WebSocket connections; "warn" is the default.

The Route Handler that wraps the service is tested the same way, now with the dynamic context:

tests/vitest/rating-route.test.ts
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { GET } from "@/app/api/books/[id]/rating/route";

const server = setupServer();
beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// In a dynamic Route Handler the second argument carries the params as a Promise.
const ctx = (id: string) => ({ params: Promise.resolve({ id }) }) as RouteContext<"/api/books/[id]/rating">;

test("proxies the rating of the third-party service", async () => {
  server.use(
    http.get("https://ratings.example.test/api/ratings/:id", () =>
      HttpResponse.json({ bookId: "1", average: 4.5, count: 12 }),
    ),
  );
  const res = await GET(new Request("http://localhost/api/books/1/rating"), ctx("1"));
  expect(res.status).toBe(200);
  expect(await res.json()).toEqual({ bookId: "1", average: 4.5, count: 12 });
});

test("maps a failing service to 502", async () => {
  server.use(http.get("https://ratings.example.test/api/ratings/:id", () => new HttpResponse(null, { status: 500 })));
  const res = await GET(new Request("http://localhost/api/books/1/rating"), ctx("1"));
  expect(res.status).toBe(502);
});

MSW under Jest

MSW 3 is published as ES modules. Under Jest 30 and Node.js 24.16 the first import fails with Must use import to load ES Module for msw/lib/core/index.js, and after that for each of its ESM-only dependencies in turn. The fix is the transformIgnorePatterns list in jest.config.ts above, which tells Jest to run those packages through the Next.js compiler; the list in the config was assembled by trial, adding the package named in each error. The test then needs the Node environment (a docblock again), because jsdom has no fetch:

tests/jest/ratings-msw.test.ts
/**
 * @jest-environment node
 */
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { getRating } from "@/lib/ratings";

const server = setupServer(
  http.get("https://ratings.example.test/api/ratings/:id", () =>
    HttpResponse.json({ bookId: "1", average: 4.5, count: 12 }),
  ),
);

beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it("returns the rating from the mocked service", async () => {
  expect(await getRating("1")).toEqual({ bookId: "1", average: 4.5, count: 12 });
});

What MSW cannot intercept

setupServer patches the network APIs of the test process. In an end-to-end test the Next.js server is a separate process (next start), and the fetch in getRating runs there, so an MSW handler in the Playwright test file never sees it. For server-side calls in an end-to-end test, point the application at a stand-in service instead. The RATINGS_API_URL variable exists for this reason, and Playwright starts a tiny fake next to the app (see the Playwright section). The same holds for a browser: to mock what the browser requests, use Playwright’s own page.route, which works in the browser context.

Environment Variables in Tests

Next.js loads .env files by environment, and test is the third environment next to development and production. When NODE_ENV is test, it reads .env.test and not .env.local, so that every machine produces the same results. Commit .env.test; .env*.local stay out of Git. Under Jest, next/jest does this for you. Under Vitest, call loadEnvConfig from @next/env:

tests/vitest/env.test.ts (with RATINGS_API_URL set in .env.test, and a different value in .env.local)
import { expect, test } from "vitest";
import { loadEnvConfig } from "@next/env";

test("loadEnvConfig reads .env.test and ignores .env.local", () => {
  loadEnvConfig(process.cwd());
  expect(process.env.RATINGS_API_URL).toBe("https://ratings.example.test");
});

In a real project put the call in the setup file. The Jest equivalent is a plain expect on process.env: it passed with no code at all, which is the proof that next/jest loaded the file. Only obvious placeholders belong in .env.test; a real secret does not, because the file is committed. See Configuration, TypeScript and Environment Variables.

Playwright End-to-End Tests

Playwright drives Chromium, Firefox and WebKit through one API, and its test runner can start your application for you. The Next.js guide recommends testing the production build because that is what users get. The quickest start is npx create-next-app@latest --example with-playwright; the manual route is npm init playwright (or install @playwright/test and write the config yourself).

playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

const port = Number(process.env.PORT ?? 3000);
const fakePort = Number(process.env.FAKE_RATINGS_PORT ?? 3904);

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  reporter: "list",
  use: { baseURL: `http://localhost:${port}`, trace: "on-first-retry" },
  projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
  webServer: [
    {
      // the third-party service, replaced by a fake: MSW cannot intercept calls made by the Next.js server process
      command: "node e2e/fake-ratings.mjs",
      url: `http://localhost:${fakePort}/api/ratings/1`,
    },
    {
      // test the production build, as the Next.js guide recommends
      command: `npm run build && npm run start -- --port ${port}`,
      url: `http://localhost:${port}`,
      env: { RATINGS_API_URL: `http://localhost:${fakePort}` },
      reuseExistingServer: !process.env.CI,
      timeout: 180_000,
    },
  ],
});

webServer runs next build and next start and waits until the URL answers; with reuseExistingServer a server you already started is reused outside CI. The official page also notes that next dev can be used instead, at the price of testing a different build. The fake service is a few lines of Node:

e2e/fake-ratings.mjs
// A tiny stand-in for the third-party ratings service, started by Playwright next to the app.
import { createServer } from "node:http";

const port = Number(process.env.FAKE_RATINGS_PORT ?? 3904);

createServer((req, res) => {
  const match = /^\/api\/ratings\/(\w+)$/.exec(req.url ?? "");
  if (match && match[1] === "1") {
    res.setHeader("content-type", "application/json");
    res.end(JSON.stringify({ bookId: "1", average: 4.5, count: 12 }));
  } else {
    res.statusCode = 404;
    res.end();
  }
}).listen(port, () => console.log(`fake ratings service on ${port}`));

The tests exercise what the unit tests cannot: the proxy redirect, search, navigation into an intercepted route, a Server Action posted by a real form, and two Route Handlers through the HTTP interface.

e2e/bookshelf.spec.ts
import { test, expect } from "@playwright/test";

test("redirects to the preferred locale and shows the catalogue", async ({ browser }) => {
  const context = await browser.newContext({ locale: "es-ES", extraHTTPHeaders: { "accept-language": "es" } });
  const page = await context.newPage();
  await page.goto("/");
  await expect(page).toHaveURL(/\/es$/);
  await expect(page.getByRole("heading", { level: 1 })).toHaveText("Estantería");
  await context.close();
});

test("searches the catalogue and opens a book", async ({ page }) => {
  await page.goto("/en");
  await page.getByRole("textbox", { name: "Search" }).fill("sol");
  await page.getByRole("button", { name: "Search" }).click();
  await expect(page).toHaveURL(/q=sol/);
  await page.getByRole("link", { name: "Solaris" }).click();
  await expect(page).toHaveURL(/\/en\/books\/3$/);
  await expect(page.getByRole("heading", { name: "Solaris" })).toBeVisible();
});

test("an anonymous visitor cannot post a review (Server Action)", async ({ page }) => {
  await page.goto("/en/books/1");
  await page.getByRole("textbox").fill("Great book");
  await page.getByRole("button", { name: "Send review" }).click();
  await expect(page.getByText("Sign in required")).toBeVisible();
});

test("the Route Handler answers with JSON", async ({ request }) => {
  const res = await request.get("/api/books?q=dune");
  expect(res.ok()).toBe(true);
  expect(await res.json()).toEqual([{ id: "1", title: "Dune", author: "Frank Herbert", priceCents: 999 }]);
});

test("the rating comes from the (fake) third-party service", async ({ request }) => {
  const ok = await request.get("/api/books/1/rating");
  expect(await ok.json()).toEqual({ bookId: "1", average: 4.5, count: 12 });
  const missing = await request.get("/api/books/2/rating");
  expect(missing.status()).toBe(404);
});

Two locator problems came up, both caused by Next.js itself rather than by the test:

  • getByRole("alert") matched two elements. The App Router injects an empty <div role="alert" id="next-route-announcer"> for screen readers, so Playwright’s strict mode refuses an ambiguous locator. Query by the text of the message, or by a test id, instead of by the alert role alone.

  • getByRole("heading", { level: 1 }) matched two elements after the click. The catalogue links to /en/books/3, and the Bookshelf’s intercepting route renders the book in the @modal slot over the catalogue, so the catalogue’s own <h1> is still on the page. Name the heading you expect. See Parallel and Intercepting Routes.

Running the Playwright suite against the Cache Components build (final run)
$ CACHE_COMPONENTS=1 PORT=3804 npx playwright test

Running 7 tests using 5 workers
  ✓  [chrome] › e2e/bookshelf.spec.ts:29:5 › the Route Handler answers with JSON (21ms)
  ✓  [chrome] › e2e/bookshelf.spec.ts:35:5 › the rating comes from the (fake) third-party service (36ms)
  ✓  [chrome] › e2e/bookshelf.spec.ts:22:5 › an anonymous visitor cannot post a review (Server Action) (792ms)
  ✓  [chrome] › e2e/bookshelf.spec.ts:3:5 › redirects to the preferred locale and shows the catalogue (827ms)
  ✓  [chrome] › e2e/instant.spec.ts:5:7 › Product page (/en/store/[slug]) › is instant on an initial page load (916ms)
  ✓  [chrome] › e2e/bookshelf.spec.ts:12:5 › searches the catalogue and opens a book (1.2s)
  ✓  [chrome] › e2e/instant.spec.ts:20:7 › Product page (/en/store/[slug]) › is instant on a client navigation (1.3s)
  7 passed (6.2s)

The four bookshelf.spec.ts flows passed the same way against a build of the previous model (without cacheComponents).

Browsers. Playwright pins a browser build to each release. After installing or upgrading @playwright/test, run npx playwright install to download the matching browsers, and npx playwright install-deps on a CI image to install the system libraries. In the sandbox used to verify this page the Chromium build matching Playwright 1.63.0 was not installed and was not downloaded, so a local override of the config pointed the project at the installed Google Chrome with channel: "chrome"; the tests and the config shown here are otherwise unchanged. Run the Playwright suite in CI against the production build, with the headless default; the E2E Testing in Real Browsers page covers traces, videos, sharding and the GitHub Actions workflow.

Asserting Instant Navigations with instant()

Next.js 16.3 added Instant Navigations: with cacheComponents and partialPrefetching enabled, a navigation can render its static shell and cached content at once while the rest streams into <Suspense> fallbacks. Whether a route really behaves that way is easy to break with an innocent change (an uncached fetch above a boundary), and nothing else in the build will tell you. The @next/playwright package exports instant(page, callback, options?), which holds back dynamic content while the callback runs, so assertions see only what a user sees the instant the navigation happens. See Partial Prerendering and Cache Components for the model.

sequenceDiagram participant T as Test participant P as Browser page participant S as Next.js server T->>P: instant(page, callback) Note over P: dynamic content is held back, like Pause on navigations T->>P: page.goto or click a Link P->>S: request S-->>P: static shell and prefetched UI only T->>P: assertions see the instant UI T->>P: callback ends, hold released like Resume S-->>P: dynamic content streams in T->>P: assertions see the final UI

The route under test is a product page with a cached product name and a request-time inventory. generateStaticParams makes the slug known at build time (so the name can be in the shell), and connection() marks the inventory as request data, so it can only appear in a <Suspense> fallback:

app/[locale]/store/[slug]/page.tsx
import { Suspense } from "react";
import Link from "next/link";
import { connection } from "next/server";

export function generateStaticParams() {
  return ["en", "es"].flatMap((locale) => ["shoes", "hats"].map((slug) => ({ locale, slug })));
}

export default function ProductPage(props: PageProps<"/[locale]/store/[slug]">) {
  return (
    <main>
      <nav>
        <Link href="/en/store/shoes" prefetch>
          Shoes
        </Link>{" "}
        <Link href="/en/store/hats" prefetch>
          Hats
        </Link>
      </nav>
      <Suspense fallback={<p>Loading product...</p>}>
        <ProductInfo params={props.params} />
      </Suspense>
      <Suspense fallback={<p>Checking availability...</p>}>
        <Inventory params={props.params} />
      </Suspense>
    </main>
  );
}

type Params = PageProps<"/[locale]/store/[slug]">["params"];

const catalogue: Record<string, string> = { shoes: "Running Shoes", hats: "Baseball Cap" };

async function ProductInfo({ params }: { params: Params }) {
  const { slug } = await params;
  const name = await getProductName(slug);
  return <h1>{name}</h1>;
}

async function getProductName(slug: string) {
  "use cache";
  return catalogue[slug] ?? "Unknown product";
}

async function Inventory({ params }: { params: Params }) {
  await connection(); // request-time data: never part of the static shell
  const { slug } = await params;
  return <p>In stock: {slug.length * 7}</p>;
}

Enable the three settings. exposeTestingApiInProductionBuild is what lets instant() work against next start; in next dev the testing API is on by default.

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  experimental: { exposeTestingApiInProductionBuild: true },
};

export default nextConfig;
e2e/instant.spec.ts
import { test, expect } from "@playwright/test";
import { instant } from "@next/playwright";

test.describe("Product page (/en/store/[slug])", () => {
  test("is instant on an initial page load", async ({ page, baseURL }) => {
    await instant(
      page,
      async () => {
        await page.goto("/en/store/hats");
        // static shell: the cached product name is there, the request-time inventory is still a fallback
        await expect(page.getByRole("heading", { name: "Baseball Cap" })).toBeVisible();
        await expect(page.getByText("Checking availability...")).toBeVisible();
      },
      { baseURL },
    );
    // outside the scope the dynamic content streams in
    await expect(page.getByText("In stock: 28")).toBeVisible();
  });

  test("is instant on a client navigation", async ({ page }) => {
    await page.goto("/en/store/shoes");
    await expect(page.getByText("In stock: 35")).toBeVisible();
    await page.waitForLoadState("networkidle"); // let the <Link> prefetches finish
    await instant(page, async () => {
      await page.click('a[href="/en/store/hats"]');
      await page.waitForURL((url) => url.pathname === "/en/store/hats");
      await expect(page.getByRole("heading", { name: "Baseball Cap" })).toBeVisible();
      await expect(page.getByText("Checking availability...")).toBeVisible();
    });
    await expect(page.getByText("In stock: 28")).toBeVisible();
  });
});

Two entry points, two tests, because a <Suspense> boundary can cover one and not the other: an initial page load shows the static shell from the document response, and a client navigation shows the destination’s prefetched UI. Pass Playwright’s baseURL to instant() when page.goto() is the first navigation, because the helper needs the origin before it requests the document. Inside a client-navigation callback, wait for the destination URL before asserting, or a selector shared with the source page can match the wrong one.

What this run taught beyond the official example:

  • It only has teeth when the flag is on. Built without exposeTestingApiInProductionBuild, the same tests run against next start and fail at Checking availability…​: nothing is held back, the inventory streams in, and the fallback is gone. A test that only asserts what should be present (the heading) would pass in both cases, so assert the fallback or the absence of the dynamic text.

  • The route has to be instant for the test to pass. With no generateStaticParams, the name depends on params that are unknown at build time, and the initial-load test fails because the heading is not in the shell. On the client navigation, links without prefetch kept the old page on screen for the whole assertion timeout (the URL had changed, the content had not); with prefetch on the links the new product name rendered at once. The Navigation Inspector and the instant-navigation insights of the dev overlay show the same thing interactively.

  • The previous page stays in the DOM, hidden. After the click, two <h1> elements existed. Role-based locators ignore hidden elements, so getByRole("heading", { name }) is stable where locator("h1") fails strict mode, and toHaveCount(0) on text of the old page fails for the same reason.

  • Wait for the prefetches. The client-navigation test waits for networkidle before opening the scope; clicking before the prefetch finishes tests a slower path.

  • Requires Cache Components. In a project without cacheComponents the route above does not even build (use cache needs the flag), and instant as a segment export is documented for Cache Components only. The dev-time validation of instant navigations is separate: with Cache Components, every Page and Default segment is validated in development (validationLevel: 'warning'), and a segment can opt out with export const instant = false.

  • The cookie is shared across ports. The testing mode uses a next-instant-navigation-testing cookie, and cookies are scoped to the host, not the port; two projects on localhost can interfere. Clear the cookie when switching projects.

Cypress End-to-End and Component Tests

Cypress runs the tests inside the browser alongside the application and offers two modes: E2E testing against a running server and component testing that mounts one component without a server. The guide’s quick start is npx create-next-app@latest --example with-cypress; the manual one is npm install -D cypress, a cypress:open script and a first npm run cypress:open, which creates cypress.config and the cypress folder.

How this section was verified. Cypress needs its own binary, which npm install downloads into a cache on first use. The current release is Cypress 16.1.1, and its binary was not available in the environment used for this page; a download was not made. The only binary in the cache was 13.17.0, so the E2E spec below was run with cypress@13.17.0 against the production build and passed, while the component spec could not be run: cypress run --component with Cypress 13.17.0 and Next.js 16.3.8 stops with TypeError: webpackModule.init is not a function from the Next.js dev-server handler of Cypress. The current Cypress documentation lists Next.js 15 and 16 as supported for component testing, so the component spec and the support file below follow the official Next.js and Cypress pages and have not been executed here.

cypress.config.ts
import { defineConfig } from "cypress";

export default defineConfig({
  e2e: {
    baseUrl: "http://localhost:3804",
    supportFile: false,
  },
  component: {
    devServer: {
      framework: "next",
      bundler: "webpack",
    },
    supportFile: "cypress/support/component.ts",
  },
});

bundler: 'webpack' is what the official page and the Cypress documentation show for Next.js, even though Next.js 16 uses Turbopack for its own build; the component dev server is Cypress’s own webpack set-up. In the Cypress 13 run the baseUrl replaced the http://localhost:3000 in cy.visit, as the Next.js page suggests. Use a baseUrl that matches your port.

cypress/e2e/app.cy.ts (executed with Cypress 13.17.0)
describe("Navigation", () => {
  it("opens a book from the catalogue", () => {
    cy.visit("/en");
    cy.get('a[href="/en/books/1"]').click();
    cy.url().should("include", "/en/books/1");
    cy.contains("h1", "Dune");
  });

  it("answers the Route Handler with JSON", () => {
    cy.request("/api/books?q=gibson").its("body").should("have.length", 1);
  });
});
Running the E2E spec against the production build (Cypress 13.17.0, trimmed)
$ npm run build && npm run start -- --port 3804
$ npx cypress run --e2e

  Navigation
    ✓ opens a book from the catalogue (478ms)
    ✓ answers the Route Handler with JSON (13ms)

  2 passing (522ms)
Component test and support file (not executed here)
// cypress/support/component.ts
import { mount } from "cypress/react";

declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace Cypress {
    interface Chainable {
      mount: typeof mount;
    }
  }
}

Cypress.Commands.add("mount", mount);

// cypress/component/book-card.cy.tsx
import { BookCard } from "../../components/book-card";

describe("<BookCard />", () => {
  it("renders the title, author and price", () => {
    cy.mount(<BookCard id="1" title="Dune" author="Frank Herbert" priceCents={999} />);
    cy.get("h2").contains("Dune");
    cy.contains("€9.99");
    cy.get('a[href="/en/books/1"]').should("be.visible");
  });
});

The same limits apply as for Vitest: Cypress does not support component testing of async Server Components, and since component tests need no Next.js server, features that rely on one (such as <Image />) may not work out of the box. Cypress’s own documentation adds that a page whose props come from getServerSideProps or getStaticProps cannot get them in a component test, and recommends E2E tests for pages and component tests for individual components.

One problem did show up and is independent of the Cypress version: the project’s tsconfig.json includes every .ts file, and next build type-checks them all. With the Cypress types in the program, the global expect is Chai’s, which collides with the global expect that @types/jest declares, and the build fails with errors such as Property 'toBeInTheDocument' does not exist on type 'Assertion' in the Jest tests (and Property 'mount' does not exist in the component spec until the support file declares it). The usual layout avoids it: a cypress/tsconfig.json that loads the Cypress types, and an exclude of the Cypress files in the root config.

cypress/tsconfig.json
{
  "compilerOptions": {
    "target": "es2017",
    "lib": ["es2017", "dom"],
    "jsx": "react-jsx",
    "moduleResolution": "bundler",
    "module": "esnext",
    "types": ["cypress", "node"],
    "esModuleInterop": true,
    "skipLibCheck": true,
    "strict": true,
    "paths": { "@/*": ["../*"] },
    "noEmit": true
  },
  "include": ["**/*.ts", "**/*.tsx", "../cypress.config.ts"]
}
tsconfig.json (the changed line)
{
  "exclude": ["node_modules", "cypress", "cypress.config.ts"]
}

For CI, run Cypress headless with cypress run; the guide adds scripts that start the server with the start-server-and-test package, for example "e2e:headless": "start-server-and-test dev http://localhost:3000 \"cypress run --e2e\"", and "component:headless": "cypress run --component". Prefer the production server (start instead of dev) for the same reason as with Playwright. Cypress 16.1.1 requires Node.js ^22.0.0 or ^24.0.0 or >=26.0.0, which is narrower than the Node.js 20.9 that Next.js accepts.

Playwright or Cypress

The Next.js documentation presents them as equals; the choice is practical.

Table 4. Playwright and Cypress for a Next.js application
Playwright Cypress

Browsers

Chromium, Firefox and WebKit through one API

Chrome-family browsers, Firefox and Electron

Next.js integration

Starts and waits for the server through webServer; @next/playwright adds instant() for Cache Components

No Next.js-specific E2E helper; start the server with a script or start-server-and-test

Component tests

Not part of the Next.js guide

Supported with framework: 'next', with the async Server Component limit

API tests without a browser

The request fixture

cy.request

Style

Tests run in Node and drive the browser over a protocol; await everywhere

Tests run in the browser; a command queue instead of await

For the details of both, and for Selenium, see E2E Testing in Real Browsers.

The Pages Router

Everything above except the App Router specifics applies unchanged: the same runners, the same next/jest helper, the same Playwright and Cypress configs. The official testing section has a separate Pages Router edition with the same four guides. What differs is the code under test, and it is simpler: getServerSideProps and getStaticProps are plain async functions, and a pages/api route is a function of a request and a response object.

pages/legacy.tsx, pages/api/ping.ts and their test
// pages/legacy.tsx
import type { GetServerSideProps } from "next";

type Props = { greeting: string };

export const getServerSideProps: GetServerSideProps<Props> = async ({ query }) => ({
  props: { greeting: `Hello ${String(query.name ?? "reader")}` },
});

export default function Legacy({ greeting }: Props) {
  return <h1>{greeting}</h1>;
}

// pages/api/ping.ts
import type { NextApiRequest, NextApiResponse } from "next";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "GET") {
    res.setHeader("Allow", "GET");
    return res.status(405).json({ error: "Method Not Allowed" });
  }
  return res.status(200).json({ pong: String(req.query.name ?? "world") });
}

// __tests__/vitest/pages-router.test.tsx
import { expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from "next";
import Legacy, { getServerSideProps } from "@/pages/legacy";
import handler from "@/pages/api/ping";

test("getServerSideProps is a plain async function", async () => {
  const result = await getServerSideProps({ query: { name: "Ada" } } as unknown as GetServerSidePropsContext);
  expect(result).toEqual({ props: { greeting: "Hello Ada" } });
  render(<Legacy greeting="Hello Ada" />);
  expect(screen.getByRole("heading", { name: "Hello Ada" })).toBeInTheDocument();
});

test("a Pages Router API route takes req and res objects", () => {
  const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), setHeader: vi.fn() };
  handler({ method: "GET", query: { name: "Ada" } } as unknown as NextApiRequest, res as unknown as NextApiResponse);
  expect(res.status).toHaveBeenCalledWith(200);
  expect(res.json).toHaveBeenCalledWith({ pong: "Ada" });

  handler({ method: "POST", query: {} } as unknown as NextApiRequest, res as unknown as NextApiResponse);
  expect(res.setHeader).toHaveBeenCalledWith("Allow", "GET");
  expect(res.status).toHaveBeenLastCalledWith(405);
});

A Pages Router page that fetches in getServerSideProps has the same Cypress caveat as before: the data function does not run in a component test, so test the page end to end and test the data function separately, as above. A component that calls useRouter from next/router needs a mock of that module, exactly like the next/navigation mock earlier.

Pitfalls

Table 5. Testing pitfalls and what they look like
Pitfall Symptom and fix

import "server-only" in a unit-tested module

This module cannot be imported from a Client Component module. Mock server-only in the setup file

Using next/navigation or next/router hooks in a component under test

invariant expected app router to be mounted or NextRouter was not mounted. Mock the module, or test end to end

Rendering an async Server Component with render()

An empty container and is an async Client Component. Call it as a function for a leaf, or write an end-to-end test

Calling cookies(), headers() or revalidatePath in a unit test

cookies was called outside a request scope, Invariant: static generation store missing. Mock next/headers and next/cache, or wrap the logic

Passing plain objects as params to a dynamic handler or page

Since Next.js 15 params is a Promise; use Promise.resolve({ …​ }) in the context

Vitest 5 with the scaffold’s @types/node 20

npm ERESOLVE; raise @types/node to 22 or 24 and later

ESM-only packages (MSW 3) under Jest

Must use import to load ES Module; extend transformIgnorePatterns or choose Vitest

MSW handler in an end-to-end test

The server-side fetch is not intercepted, because it runs in another process. Point the app at a fake service by configuration

getByRole("alert") in an App Router app

Two matches, one is the route announcer. Query by text or test id

locator("h1") after a client navigation, or text of the old page

The previous route stays in the DOM, hidden. Use role locators, or assert on the fallback

instant() passes when it should fail

Built without exposeTestingApiInProductionBuild, so nothing is held back. Assert the fallback, not only the content

Running end-to-end tests against next dev

Slower and different from production (no static optimisation, different caching). Test the production build

Cypress and Jest types in one TypeScript program

Property 'toBeInTheDocument' does not exist on type 'Assertion' in next build. Separate cypress/tsconfig.json and exclude the folder

Different CACHE_COMPONENTS or config at build and at start

DYNAMIC_SERVER_USAGE and HTTP 500 at runtime: a build made with one caching model served with the config of another. Build and start with the same settings

What Changed Recently

Table 6. Testing across Next.js, React and tool versions
Version Change

Next.js 12 (2021)

next/jest arrives with SWC, so Jest needs no Babel configuration for Next.js

Next.js 13.4 (2023)

App Router stable; async Server Components appear, and the tools cannot render them. The official overview still recommends end-to-end tests for them

Next.js 15 (2024-10-21)

params, searchParams, cookies() and headers() become Promises; test contexts and mocks must return Promises

Next.js 15.1 (2024-12-10)

Experimental next/experimental/testing/server utilities for unit testing the middleware, later the proxy

Next.js 16 (2025-10-21)

middleware becomes proxy (Node.js runtime); Turbopack is the default bundler, while Cypress component testing still names the webpack bundler; next lint is removed, so test scripts do not depend on it

Next.js 16.3 (2026-08-03)

Instant Navigations, the Navigation Inspector and the instant() helper in @next/playwright; experimental.exposeTestingApiInProductionBuild for production builds; Cache Components required

Vitest 5 (current: 5.0.3)

Runs on Vite 6.4, 7 or 8 and requires @types/node 22 or 24 and later; with Vite 8 the resolve.tsconfigPaths option replaces the vite-tsconfig-paths plugin that the official guide still shows; @vitejs/plugin-react 6 needs Vite 8

Jest 30 (current: 30.5.2)

Works with next/jest; ESM-only dependencies still need a transformIgnorePatterns entry

MSW 3 (current: 3.0.2)

ESM only, Node.js 22.12 or later; server.listen({ onUnhandledRequest }) becomes onUnhandledFrame

Playwright 1.63 (current)

Browsers are versioned with the package; run npx playwright install after every upgrade

Cypress 16 (current: 16.1.1)

Node.js 22, 24 or 26 and later; component testing documented for Next.js 15 and 16; mount is imported from cypress/react

The pages of the official guide are not all equally fresh. In the 16.3.8 documentation the overview was last updated 2026-02-03, the Cypress guide 2026-02-11, and the Vitest, Jest and Playwright guides 2026-08-25; the stale spots found while running them are the vite-tsconfig-paths plugin, the unstable_doesProxyMatch name and the manual moduleNameMapper, all described above.

Where the Books Are Outdated

Both consulted books teach testing from the Pages Router era. In Real-World Next.js (Packt, 2022) the unit tests use Jest installed by hand, with a .babelrc that contains the next/babel preset so that Jest can transpile the project, and the component tests compare React Testing Library with Enzyme; Cypress is used for end-to-end and component tests. In Practical Next.js for E-Commerce (Apress, 2023) Cypress is the only tool, configured with npm run cypress and a baseUrl, and the end-to-end tests run against the development server. What to use today:

  • Jest set-up: next/jest, which configures Jest with the Next.js compiler, instead of a hand-written .babelrc with the next/babel preset.

  • Component testing: React Testing Library, with the @testing-library/jest-dom/vitest or jest-dom matchers. The current Next.js guides do not mention Enzyme at all.

  • Runner choice: Vitest is a first-class option in the official guides now; the books predate it.

  • End-to-end target: the production build (next build and next start), not the development server.

  • Playwright: absent from both books and now documented next to Cypress, with a Next.js-specific helper, instant(), from 16.3.

  • What is testable: the books test Pages Router pages whose data comes from getServerSideProps. With the App Router, async Server Components cannot be rendered by Testing Library; extract the logic, or test end to end.

  • Server Actions and Route Handlers: neither book has them; test them as plain functions plus an HTTP or browser test. The security habit that matters most, testing the negative authorisation cases, is also what the books' client-side checks missed.

  • Cypress details: the component-testing setup and the TypeScript requirement (Cypress below 13.6.3 did not support TypeScript 5 with moduleResolution: "bundler", per the Next.js page) changed after the books were written.

The requester’s books are listed in the section bibliography; no text, listing or figure of them is reproduced here.