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.
| Code under test | Tool | Notes |
|---|---|---|
Pure functions, hooks, utilities |
Vitest or Jest |
Fast and runner-agnostic. |
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 ( |
|
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 |
Route Handlers |
Vitest or Jest calling |
The handlers are functions from |
Proxy |
Vitest or Jest for the function and its matcher, end-to-end for the redirect |
|
Instant navigation (Cache Components) |
Playwright with |
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.
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
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:
| Package | Version | Role |
|---|---|---|
|
5.0.3 |
Test runner for the unit and component tests. Needs Vite 6.4, 7 or 8 and, as a peer dependency, |
|
6.1.1 |
JSX transform for Vitest. Version 6 requires Vite 8 |
|
30.1.1 |
DOM implementation for the |
|
30.5.2 |
The Jest alternative |
|
16.3.3, 10.4.2, 14.6.7, 7.0.1 |
Rendering, queries, interaction and matchers; |
|
3.0.2 |
Network mocking in Node (requires Node.js 22.12 or later) |
|
1.63.0, 16.3.8 |
End-to-end runner and the |
|
16.1.1 is current; 13.17.0 was run |
See the Cypress section |
|
24.19.1 |
Raised from the scaffold’s |
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:
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.
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"],
},
});
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):
{
"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):
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
"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();
});
$ npm test
RUN v5.0.3 /path/to/bookshelf
Test Files 11 passed (11)
Tests 23 passed (23)
Duration 1.09s
Source: Setting up Vitest with Next.js
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
transformthat uses the Next.js Compiler -
automatic mocks for stylesheets (
.css,.module.cssand their Sass variants), image imports andnext/font -
loading of
.envfiles (and their variants) intoprocess.env -
ignoring
node_modulesand.nextfor module resolution, andnode_modulesfor transforms -
loading of
next.config.jsfor the flags that enable SWC transforms
Install Jest and the Testing Library packages, then write the config with the helper:
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.
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)$",
],
};
}
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).
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();
});
});
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>
`;
$ 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).
Source: Setting up Jest with Next.js
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.
| Vitest | Jest with next/jest | |
|---|---|---|
Transform |
The transform pipeline of Vite, plus |
Next.js compiler (SWC) through |
Config |
|
|
ESM-only dependencies |
Work without configuration (MSW 3 needed nothing) |
Must be listed in |
Globals |
Explicit imports ( |
|
Env files |
Call |
Loaded by |
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
"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.
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 anactscope. 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
asyncis still a promise-returning function in the tree and renders nothing; a page that awaitsparamsor readscookies()orheaders()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:
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.mockcalls are hoisted above the imports by Vitest; theauthfunction 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-datarequest 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 anduseActionStatetogether. 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.
Source: Mutating Data, use server
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):
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:
/**
* @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:
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.
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.
Source: proxy.js: unit testing
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:
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;
}
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:
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:
/**
* @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:
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).
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:
// 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.
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 thealertrole 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@modalslot over the catalogue, so the catalogue’s own<h1>is still on the page. Name the heading you expect. See Parallel and Intercepting Routes.
$ 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.
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:
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.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
experimental: { exposeTestingApiInProductionBuild: true },
};
export default nextConfig;
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 againstnext startand fail atChecking 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 onparamsthat are unknown at build time, and the initial-load test fails because the heading is not in the shell. On the client navigation, links withoutprefetchkept the old page on screen for the whole assertion timeout (the URL had changed, the content had not); withprefetchon 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, sogetByRole("heading", { name })is stable wherelocator("h1")fails strict mode, andtoHaveCount(0)on text of the old page fails for the same reason. -
Wait for the prefetches. The client-navigation test waits for
networkidlebefore opening the scope; clicking before the prefetch finishes tests a slower path. -
Requires Cache Components. In a project without
cacheComponentsthe route above does not even build (use cacheneeds the flag), andinstantas 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 withexport const instant = false. -
The cookie is shared across ports. The testing mode uses a
next-instant-navigation-testingcookie, and cookies are scoped to the host, not the port; two projects onlocalhostcan 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.
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.
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);
});
});
$ 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)
// 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.
{
"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"]
}
{
"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.
| 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 |
No Next.js-specific E2E helper; start the server with a script or |
Component tests |
Not part of the Next.js guide |
Supported with |
API tests without a browser |
The |
|
Style |
Tests run in Node and drive the browser over a protocol; |
Tests run in the browser; a command queue instead of |
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
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
| Pitfall | Symptom and fix |
|---|---|
|
|
Using |
|
Rendering an |
An empty container and |
Calling |
|
Passing plain objects as |
Since Next.js 15 |
Vitest 5 with the scaffold’s |
npm |
ESM-only packages (MSW 3) under Jest |
|
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 |
|
Two matches, one is the route announcer. Query by text or test id |
|
The previous route stays in the DOM, hidden. Use role locators, or assert on the fallback |
|
Built without |
Running end-to-end tests against |
Slower and different from production (no static optimisation, different caching). Test the production build |
Cypress and Jest types in one TypeScript program |
|
Different |
|
What Changed Recently
| Version | Change |
|---|---|
Next.js 12 (2021) |
|
Next.js 13.4 (2023) |
App Router stable; |
Next.js 15 (2024-10-21) |
|
Next.js 15.1 (2024-12-10) |
Experimental |
Next.js 16 (2025-10-21) |
|
Next.js 16.3 (2026-08-03) |
Instant Navigations, the Navigation Inspector and the |
Vitest 5 (current: 5.0.3) |
Runs on Vite 6.4, 7 or 8 and requires |
Jest 30 (current: 30.5.2) |
Works with |
MSW 3 (current: 3.0.2) |
ESM only, Node.js 22.12 or later; |
Playwright 1.63 (current) |
Browsers are versioned with the package; run |
Cypress 16 (current: 16.1.1) |
Node.js 22, 24 or 26 and later; component testing documented for Next.js 15 and 16; |
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.babelrcwith thenext/babelpreset. -
Component testing: React Testing Library, with the
@testing-library/jest-dom/vitestorjest-dommatchers. 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 buildandnext 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,asyncServer 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.