#Testing a Next.js app
AddedA Next.js app mixes three kinds of code, and each needs a different kind of test. There's plain logic (price formatting, validation, mapping database rows to DTOs), which is just TypeScript. There are components that render the same way in any React environment: Client Components and synchronous Server Components. And there's code whose behaviour is the Next.js server: async Server Components awaiting data, cookies() and headers(), Suspense streaming and the Partial Prerendering shell, 'use cache' and revalidation, proxy.ts redirects, real client-side navigation.
The mental model that sorts everything else: Vitest and Jest give you a pretend browser, not a pretend Next.js server. React Testing Library's render() uses react-dom/client, the same renderer the browser uses, inside jsdom (a DOM written in JavaScript). There is no request, no React Server Components renderer, no cache and no router. Anything that only works inside a request on the server has to be tested against a real server: next build && next start, driven by Playwright. The bundled Next.js docs say the same thing in their own words: Vitest and Jest don't support async Server Components yet, so use end-to-end tests for those.
That gives the usual split: many fast unit tests for logic and interactive components, a smaller set of end-to-end tests for the flows that cross the server (the async pages, auth cookies, mutations and caching), and type-checking plus lint on every build.
| Code | Test with | What you mock or provide |
|---|---|---|
Pure functions in lib/ | Vitest or Jest | nothing |
| Client Components | Vitest/Jest + React Testing Library | next/navigation (useRouter, usePathname…) |
| Synchronous Server Components | Vitest/Jest + React Testing Library | whatever Client Components they render need |
| Async Server Components | Playwright (or render(await Page()) for simple ones) | a running app |
| Route Handlers | call GET(new Request(url)) in Vitest; Playwright for the real routing | nothing, if they don't call cookies()/headers() |
| Server Actions | call the function in Vitest for validation logic; Playwright for the real form flow | next/cache (updateTag, revalidatePath) |
proxy.ts, caching, streaming, PPR | Playwright against next build && next start | a running app |
All the examples below ran in one project, .sandbox/nextjs/ch12/testing (a copy of the Next.js 16.4.3 template) where I installed Vitest 5.0.5, Vite 8.3.5, jsdom 30.1.3, React Testing Library 16.3.3, user-event 14.6.8, Playwright 1.64.1 and Jest 30.5.2. The app files are built by the site's checker; the test files and configs can't be (the checker's template has no test packages), so they're shown untagged with the output they really printed.
import { afterEach, expect, test, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QuantityPicker } from "@/components/quantity-picker";
const push = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
afterEach(() => {
cleanup();
push.mockClear();
});
test("counts up to max, then disables +", async () => {
const user = userEvent.setup();
render(<QuantityPicker max={2} />);
const plus = screen.getByRole("button", { name: "+" });
await user.click(plus);
expect(screen.getByLabelText("Quantity").textContent).toBe("2");
expect((plus as HTMLButtonElement).disabled).toBe(true);
});
test("navigates to the cart with the quantity", async () => {
const user = userEvent.setup();
render(<QuantityPicker />);
await user.click(screen.getByRole("button", { name: "+" }));
await user.click(screen.getByRole("button", { name: "Go to cart" }));
expect(push).toHaveBeenCalledWith("/cart?qty=2");
}); RUN v5.0.5 /Users/rohitvarma/AndroidStudioProjects/StudySites/.sandbox/nextjs/ch12/testing
✓ __tests__/quantity-picker.test.tsx > counts up to max, then disables + 85ms
✓ __tests__/quantity-picker.test.tsx > navigates to the cart with the quantity 18ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 14:16:55
Duration 581ms (environment 63%, tests 21%, import 14%, transform 2%)- The component under test (shown next) is a Client Component that keeps
qtyinuseStateand callsuseRouter()fromnext/navigationto go to the cart. In a real app that hook reads the App Router's context; in jsdom there is no App Router, so the test replaces the whole module withvi.mock("next/navigation", …). - Vitest hoists every
vi.mockcall above the imports, so whenquantity-picker.tsximportsuseRouterit gets the fake. The factory only mentionspushinside theuseRouterarrow, which runs during render, afterconst push = vi.fn()has executed. A factory that readpushdirectly would fail: I tried it and Vitest stopped with "There was an error when mocking a module… make sure there are no top level variables inside" (the fix isvi.hoisted). - First test:
max={2}, soqtystarts at1.user.click(plus)dispatches real pointer and click events, React runssetQty(q => Math.min(2, q + 1)),qtygoes1 → 2, and the+button renders withdisabledbecauseqty === max. - Second test: one click takes
qtyto2, then "Go to cart" callsrouter.push("/cart?qty=2"). The assertion checks the component's contract with the router (what it asked for), not Next.js navigation itself: that belongs to the end-to-end test further down. afterEach(cleanup)matters with Vitest's defaultglobals: false: Testing Library can only clean up automatically when a globalafterEachexists. I removed the call to check, and the second test failed with "Found multiple elements with the role "button" and name "+"", because the first test's picker was still in the document.- This is the sweet spot for unit tests: an interactive Client Component, tested in about 100 ms, with only the Next.js module it touches replaced.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function QuantityPicker({ max = 3 }: { max?: number }) {
const [qty, setQty] = useState(1);
const router = useRouter();
return (
<div>
<button onClick={() => setQty((q) => Math.max(1, q - 1))}>−</button>
<output aria-label="Quantity">{qty}</output>
<button onClick={() => setQty((q) => Math.min(max, q + 1))} disabled={qty === max}>
+
</button>
<button onClick={() => router.push(`/cart?qty=${qty}`)}>Go to cart</button>
</div>
);
}"use client"makes this a Client Component: it can hold state and handle clicks, and in the app it's rendered by the Server Component page below.−never goes below1and+never abovemax, and+is disabled at the limit, which is what the first test checks.- "Go to cart" is the only place Next.js is involved, through
useRouter().push. Keeping Next.js calls at the edges like this is what keeps components easy to unit test.
The bundled guide's setup is three dev dependencies plus a config file. I added user-event (for realistic clicks) and, because of an install error, a newer @types/node:
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/user-eventThe first attempt, with the packages the template ships, stopped with npm error ERESOLVE: vitest@5.0.5 wants @types/node ^22.0.0 || >=24.0.0 as an optional peer, and create-next-app@16.4.3 pins @types/node ^20. Adding @types/node@^24 to the same install fixed it.
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: { tsconfigPaths: true }, // "@/…" imports, as in tsconfig.json
test: {
environment: "jsdom",
include: ["__tests__/**/*.test.{ts,tsx}"], // keep Playwright's e2e/ folder out
},
});@vitejs/plugin-reactcompiles JSX and TSX. Vitest doesn't use the Next.js compiler, so Next-specific transforms (next/font, CSS Modules naming,'use cache') don't run: the directive is just an unused string to Vitest.resolve: { tsconfigPaths: true }makes@/components/…resolve liketsconfig.jsonsays. The bundled guide uses thevite-tsconfig-pathsplugin for this; with Vite 8 it printed "Vite now supports tsconfig paths resolution natively via the resolve.tsconfigPaths option", so I switched to the built-in option. Both work.environment: "jsdom"gives the testsdocumentandwindow.includekeeps Vitest away from Playwright'se2e/folder, whose files would otherwise be picked up and fail.- Run it with
npx vitest(watch mode) ornpx vitest run(once, for CI).
Without the mock: "invariant expected app router to be mounted"
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { QuantityPicker } from "@/components/quantity-picker";
test("renders without a router", () => {
render(<QuantityPicker />);
expect(screen.getByLabelText("Quantity").textContent).toBe("1");
}); FAIL __tests__/naive-picker.test.tsx > renders without a router
Error: invariant expected app router to be mounted
❯ useRouter node_modules/next/src/client/components/navigation.ts:169:11
❯ QuantityPicker src/components/quantity-picker.tsx:8:18
6| export function QuantityPicker({ max = 3 }: { max?: number }) {
7| const [qty, setQty] = useState(1);
8| const router = useRouter();
| ^rendermounts the picker withreact-dom/client. The first hook,useState, works anywhere.useRouter()fromnext/navigationreads a React context that only the App Router's root provides. In jsdom nothing provides it, so Next.js throws its "invariant expected app router to be mounted" error.- The fix is never "wrap it in the real router": mock
next/navigationwith just the members the component uses (useRouter, andusePathnameoruseSearchParamsif it reads them). - The same error in a real app means a component calling
useRouterwas rendered outside the App Router, for example from the Pages Router, which has its ownuseRouterinnext/router.
A Server Component that isn't async is a plain function returning JSX, so React Testing Library can render it in jsdom. That's the page from this app:
import Link from "next/link";
import { QuantityPicker } from "@/components/quantity-picker";
export default function Home() {
return (
<main>
<h1>Mug</h1>
<QuantityPicker max={3} />
<Link href="/products">All products</Link>
</main>
);
}import { expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Home from "@/app/page";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
test("a synchronous Server Component renders like any component", () => {
render(<Home />);
expect(screen.getByRole("heading", { level: 1, name: "Mug" })).toBeDefined();
expect(screen.getByRole("link", { name: "All products" }).getAttribute("href")).toBe("/products");
});Homehas no"use client", so in the app it renders on the server. In the test, it's just a function:render(<Home />)calls it in jsdom.- It renders
QuantityPicker, which callsuseRouter(), so this file mocksnext/navigationtoo. Without it the same invariant error appears, from inside the page. next/linkrenders a real<a href="/products">in jsdom, so the test can check the link'shref. It can't check prefetching or client navigation: there's no router to do them.- It passed (
✓ a synchronous Server Component renders like any component 75ms). This is fine for "does the page compose the right pieces" checks, but most real pages areasync, which is where unit tests stop.
This page awaits its data, like most App Router pages:
import { getProducts } from "@/lib/products";
import { formatPrice } from "@/lib/price";
export default async function ProductsPage() {
const products = await getProducts();
return (
<main>
<h1>Products</h1>
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name}: {formatPrice(p.priceCents)}
</li>
))}
</ul>
</main>
);
}export type Product = { id: string; name: string; priceCents: number };
export async function getProducts(): Promise<Product[]> {
"use cache";
// Stands in for a database query
return [
{ id: "mug", name: "Mug", priceCents: 1200 },
{ id: "tee", name: "T-shirt", priceCents: 2500 },
];
}export function formatPrice(cents: number, currency = "CAD"): string {
return new Intl.NumberFormat("en-CA", { style: "currency", currency }).format(cents / 100);
}Rendering it the way you'd render a Client Component:
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import ProductsPage from "@/app/products/page";
test("renders an async Server Component like a client one", async () => {
render(<ProductsPage />);
expect(await screen.findByText("Mug: $12.00")).toBeDefined();
});stderr | __tests__/naive-products.test.tsx > renders an async Server Component like a client one
<ProductsPage> is an async Client Component. Only Server Components can be async at the moment. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.
stderr | __tests__/naive-products.test.tsx > renders an async Server Component like a client one
A component suspended inside an `act` scope, but the `act` call was not awaited. When testing React components that depend on asynchronous data, you must await the result:
FAIL __tests__/naive-products.test.tsx > renders an async Server Component like a client one
TestingLibraryElementError: Unable to find an element with the text: Mug: $12.00. …
<body>
<div />
</body>render(<ProductsPage />)hands anasyncfunction toreact-dom/client. The browser renderer doesn't run async components: React says so in its own words, "<ProductsPage> is an async Client Component. Only Server Components can be async at the moment".- From React's point of view every component in a jsdom test is a Client Component, because there's no Server Components renderer. "Server Component" is about which renderer runs the code, not about the file.
findByTextwaits about a second (its default timeout) and gives up: the body is still an empty<div />. The test took 1009 ms, all of it waiting.- So the Next.js docs' advice holds: test async Server Components end to end. For simple ones there's a workaround, shown next, but it has limits.
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import ProductsPage from "@/app/products/page";
import { Greeting } from "@/components/greeting";
test("await the async component yourself, then render its output", async () => {
render(await ProductsPage());
expect(screen.getByText("Mug: $12.00")).toBeDefined();
});
test("request APIs have no request to read in a unit test", async () => {
await expect(Greeting()).rejects.toThrow("`cookies` was called outside a request scope");
});import { cookies } from "next/headers";
export async function Greeting() {
const user = (await cookies()).get("user")?.value ?? "guest";
return <p>Hello, {user}</p>;
}await ProductsPage()calls the component as an ordinary async function. It awaitsgetProducts()(the'use cache'string does nothing in Vitest), formats the prices, and resolves to plain JSX: a<main>with two<li>s.render()then renders that JSX, which contains no async components, so it works:Mug: $12.00is found.- This only works one level deep. If the page rendered another async component (
<Reviews />inside it), that child would hit the same "async Client Component" error, because only the top call was awaited by you. Greetingcallscookies(). Outside a Next.js request there's no request to read, and the test captured the real message:`cookies` was called outside a request scope. The same goes forheaders(),draftMode(),connection()and theparams/searchParamsthat Next.js passes in.- You could mock
next/headers, but then the test checks your mock, not the page. The usual design answer is to keep request reading at the top (in the page) and pass plain values to components and functions you can test, and to cover the page itself with an end-to-end test.
A: a Client Component that calls useRouter(). B: export default function About() returning JSX. C: export default async function Page() that awaits a database call and renders <Reviews />, another async component. D: a Server Component that reads (await headers()).get("user-agent").
Show answer
A: yes, with next/navigation mocked. B: yes, as is. C: no (and render(await Page()) doesn't save it, because <Reviews /> is async too). D: no, headers() throws "called outside a request scope". C and D are end-to-end territory.
- A is an ordinary Client Component; its only Next.js dependency is the router hook, and mocking
next/navigationremoves it (the "invariant expected app router to be mounted" error otherwise). - B is a synchronous function returning JSX: jsdom renders it like any component, as the
home.test.tsxrun showed. - C:
render(<Page />)gives "is an async Client Component". AwaitingPage()yourself resolves the top level, but the JSX it returns still contains<Reviews />, an async component, whichreact-dom/clientcan't render either. - D:
headers()needs the request that Next.js stores for the duration of a render; in a test there is none, so it throws before returning anything.
A Route Handler is a function from Request to Response, and a Server Action is an async function: both can be called directly, as long as you mock the Next.js APIs they call.
import { formatPrice } from "@/lib/price";
export function GET(request: Request) {
const cents = Number(new URL(request.url).searchParams.get("cents"));
if (!Number.isInteger(cents) || cents < 0) {
return Response.json({ error: "cents must be a whole number" }, { status: 400 });
}
return Response.json({ formatted: formatPrice(cents) });
}"use server";
import { updateTag } from "next/cache";
export async function renameProduct(formData: FormData) {
const name = String(formData.get("name") ?? "").trim();
if (name.length < 2) return { error: "Name is too short" };
// ...save to the database here...
updateTag("products");
return { ok: true, name };
}import { expect, test, vi } from "vitest";
import { updateTag } from "next/cache";
import { GET } from "@/app/api/price/route";
import { renameProduct } from "@/app/actions";
vi.mock("next/cache", () => ({ updateTag: vi.fn() }));
test("a Route Handler is a function from Request to Response", async () => {
const ok = GET(new Request("http://test/api/price?cents=1200"));
expect(ok.status).toBe(200);
expect(await ok.json()).toEqual({ formatted: "$12.00" });
const bad = GET(new Request("http://test/api/price?cents=abc"));
expect(bad.status).toBe(400);
});
test("a Server Action is an async function you can call", async () => {
const form = new FormData();
form.set("name", "Big mug");
expect(await renameProduct(form)).toEqual({ ok: true, name: "Big mug" });
expect(updateTag).toHaveBeenCalledWith("products");
form.set("name", "x");
expect(await renameProduct(form)).toEqual({ error: "Name is too short" });
});GET(new Request("http://test/api/price?cents=1200"))runs the handler with a standardRequest; it parsescents(1200), returnsResponse.json({ formatted: "$12.00" }), and the test readsstatusandjson()like any fetch response.cents=abcgivesNaN, which failsNumber.isInteger, so it's a400.- The
"use server"directive means nothing to Vitest, sorenameProduct(form)is a plain call:"Big mug"passes validation and the result is{ ok: true, name: "Big mug" };"x"returns the error object. updateTagis the catch. I first ran the test withoutvi.mock("next/cache"), and it failed with Next.js's real check: "updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead." With the mock, the test can also assertupdateTagwas called with"products".- What this doesn't test: that the form posts to the action, that the action ID resolves, that
updateTagreally refreshes the page, or the auth checks a real action needs. Those are end-to-end concerns.
These pages complete the app: the cart reads searchParams and the account page reads a cookie, both inside <Suspense> because Cache Components requires request-time data to sit in a boundary.
import { Suspense } from "react";
async function Quantity({ searchParams }: Pick<PageProps<"/cart">, "searchParams">) {
const { qty = "0" } = await searchParams; // request-time data: inside <Suspense>
return <p>Items in cart: {qty}</p>;
}
export default function CartPage({ searchParams }: PageProps<"/cart">) {
return (
<main>
<h1>Cart</h1>
<Suspense fallback={<p>Loading cart…</p>}>
<Quantity searchParams={searchParams} />
</Suspense>
</main>
);
}import { Suspense } from "react";
import { Greeting } from "@/components/greeting";
export default function AccountPage() {
return (
<main>
<h1>Account</h1>
<Suspense fallback={<p>…</p>}>
<Greeting />
</Suspense>
</main>
);
}Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ◐ /account
├ ƒ /api/price
├ ◐ /cart
└ ○ /products 15m 1y
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demandimport { defineConfig } from "@playwright/test";
const port = Number(process.env.PORT ?? 3000);
export default defineConfig({
testDir: "e2e",
use: {
baseURL: `http://127.0.0.1:${port}`,
channel: "chrome", // the installed Google Chrome; or run `npx playwright install` for bundled browsers
},
webServer: {
// Test the production build, not next dev
command: `npm run build && npm run start -- -H 127.0.0.1 -p ${port}`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});import { expect, test } from "@playwright/test";
test("the async products page renders on the server", async ({ page }) => {
await page.goto("/products");
await expect(page.getByRole("listitem")).toHaveText(["Mug: $12.00", "T-shirt: $25.00"]);
});
test("the picker navigates to the cart on the client", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "+" }).click();
await page.getByRole("button", { name: "Go to cart" }).click();
await expect(page).toHaveURL("/cart?qty=2");
await expect(page.getByText("Items in cart: 2")).toBeVisible();
});
test("the account page reads the cookie on the server", async ({ page, context, baseURL }) => {
await context.addCookies([{ name: "user", value: "rohit", url: baseURL! }]);
await page.goto("/account");
await expect(page.getByText("Hello, rohit")).toBeVisible();
});Running 3 tests using 1 worker
✓ 1 e2e/shop.spec.ts:3:5 › the async products page renders on the server (290ms)
✓ 2 e2e/shop.spec.ts:8:5 › the picker navigates to the cart on the client (283ms)
✓ 3 e2e/shop.spec.ts:16:5 › the account page reads the cookie on the server (191ms)
3 passed (7.5s)webServermakes Playwright runnpm run build && npm run startitself and wait until the URL answers, so the tests hit the production build: the same route table as above, the same caching and streaming as users get.reuseExistingServerlets you keep a server running locally; on CI (CIset) it always starts a fresh one.channel: "chrome"uses the Google Chrome already installed on the machine. Without it, Playwright uses its own browsers, which you download once withnpx playwright install(npx playwright install-depson Linux CI).- Test 1 loads
/products, the async Server Component that couldn't be unit-tested, and checks both list items. The server ran the component; the test only sees the HTML and the DOM. - Test 2 is the full version of the unit test: real clicks after hydration, a real
router.push, the URL becoming/cart?qty=2, and the cart's Suspense boundary streaming in "Items in cart: 2" fromsearchParams. - Test 3 sets a
user=rohitcookie on the browser context before loading/account, socookies()on the server reads it and the streamedGreetingsays "Hello, rohit". No mocks anywhere. - To check the tests weren't flaky I ran
--repeat-each=10 --workers=4: 30 of 30 passed in 8.9 s. Expect E2E suites to be slower and fewer than unit tests; keep them for flows that cross the server.
Jest is still everywhere in existing codebases. Next.js ships next/jest (since Next.js 12), which configures Jest to compile with the Next.js compiler (SWC), mock CSS, image and next/font imports, load .env files and next.config, and ignore .next.
import nextJest from "next/jest.js";
// Loads next.config and .env files, and compiles TS/JSX with the Next.js compiler (SWC)
const createJestConfig = nextJest({ dir: "./" });
export default createJestConfig({
testEnvironment: "jsdom",
testMatch: ["<rootDir>/jest/**/*.test.tsx"],
moduleNameMapper: { "^@/(.*)$": "<rootDir>/src/$1" },
});import { fireEvent, render, screen } from "@testing-library/react";
import { QuantityPicker } from "@/components/quantity-picker";
const mockPush = jest.fn(); // babel-jest only lets a hoisted jest.mock factory use variables named mock…
jest.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush }) }));
test("navigates to the cart with the quantity", () => {
render(<QuantityPicker />);
fireEvent.click(screen.getByRole("button", { name: "+" }));
fireEvent.click(screen.getByRole("button", { name: "Go to cart" }));
expect(mockPush).toHaveBeenCalledWith("/cart?qty=2");
});Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.324 s, estimated 1 s
Ran all test suites.nextJest({ dir: "./" })returns a function that merges your options with the Next.js defaults; it's async under the hood because it loadsnext.config, which is why the file exportscreateJestConfig(…)rather than a plain object.moduleNameMappermaps@/…tosrc/…:next/jestdoesn't readtsconfig.jsonpaths for you.testMatchkeeps Jest on its own folder, away from the Vitest and Playwright files.jest.mockis hoisted likevi.mock. Under babel-jest, a hoisted factory may only use outside variables whose names start withmock, hencemockPush. Undernext/jest's SWC transform the version namedpushalso passed when I tried it, but the prefix keeps the test portable.fireEvent.clickis the lower-level event helper; user-event works under Jest too. The Jest and Vitest APIs line up almost one to one (jest.fn/vi.fn,jest.mock/vi.mock), so the same limits apply: no async Server Components, no request APIs.
next dev