Testing Next.js Components
Unit-test a Next.js app's Client Components, Server Components and Server Actions with Vitest and Testing Library, and mock what only Next.js provides.
- 01Set up VitestAdd Vitest, jsdom and Testing Library to a Next.js app, and run its tests the way a CI job does.
- 02Test each kind of componentTest Client Components and Server Components that aren't async, and say why async ones belong in end-to-end tests.
- 03Mock what Next.js providesReplace next/navigation, cookies(), next/cache and server-only in a test, and test a Server Action as a function.
A test that renders the tea shop's home page
In the React course, Testing React components tested components with Vitest and React Testing Library: render a component, find what it shows by role, click with user-event, and check the screen. A Next.js app's pages and components are React components, so the same tools test them. What changes is what's around them. In the app, Next.js gives each component a router, a request and a server that renders it. In a test, Next.js isn't running at all, so whatever the code asks Next.js for has to be a mock: a replacement module or function that the test controls (Mocks, spies & fakes builds them in plain JavaScript).
Here is the tea shop's home page and its test, in a real Next.js 16.4.3 project. The recorder ran npx vitest run --reporter=verbose in it, the way continuous integration (CI) does: a server that runs the tests on every change, then built it with next build and asked next start for /. Click a file in the tree to read it.
app/page.test.tsximport { expect, test } from "vitest";import { render, screen } from "@testing-library/react";import userEvent from "@testing-library/user-event";import Home from "./page"; test("shows today's tea and a link to every tea", () => { render(<Home />); expect(screen.getByRole("heading", { level: 1, name: "Tea shop" })).toBeInTheDocument(); expect(screen.getByText("Tea of the day: Mint tea, $3")).toBeInTheDocument(); expect(screen.getByRole("link", { name: "See every tea" })).toHaveAttribute("href", "/teas");}); test("adds Mint tea to the cart", async () => { const user = userEvent.setup(); render(<Home />); await user.click(screen.getByRole("button", { name: "Add Mint tea" })); await user.click(screen.getByRole("button", { name: "Add Mint tea" })); expect(screen.getByText("In your cart: 2")).toBeInTheDocument();});$ npx vitest run --reporter=verbose RUN v5.0.5 /my-app ✓ app/page.test.tsx > shows today's tea and a link to every tea ✓ app/page.test.tsx > adds Mint tea to the cart Test Files 1 passed (1) Tests 2 passed (2) $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /└ ○ /_not-found ○ (Static) prerendered as static contentapp/page.test.tsx sits next to the page. It doesn't become a route: only files named page or route do. Line 4 imports Home from ./page, the same component Next.js shows at /. Home is a Server Component that isn't async: a function that returns JSX. So line 7 renders it like any component, and lines 8 to 10 find its heading, its text, and its link to /teas.
The second test clicks Add Mint tea twice (lines 16 and 17), and line 18 finds In your cart: 2. That button is AddToCart, a Client Component: app/add-to-cart.tsx starts with "use client", keeps the count in state, and Home renders it on line 10 of app/page.tsx.
The Terminal tab shows both runs. First Vitest's report: a ✓ for each test, then Tests 2 passed (2). Then $ next build, which printed ○ /. Open the Browser tab: next start sent the same heading, text and link that the test found.
Home in both rows. In a test, Next.js isn't running: Vitest calls your code, and anything the code asks Next.js for has to be a mock.Pilots practice most moves in a simulator. The controls are the real ones, but the sky is drawn on screens, and a recording plays the control tower. Each practice run is quick, and the same every time. For the whole route, with real weather and a real tower, they fly a test flight.
- In real life: The real controls of the cockpit
- In Next.js: Your component's real code
- In real life: Screens that draw the sky
- In Next.js: jsdom: a page in memory, with no browser
- In real life: A recorded voice playing the control tower
- In Next.js: A mock of
next/navigationornext/headers - In real life: A test flight on the real route
- In Next.js: An end-to-end test against
next buildandnext start
A unit test runs your component or function without Next.js. Testing Library renders it into jsdom, and anything your code asks Next.js for, like the router, the request or the cache, you replace with a mock. What only the running app does, like rendering async Server Components, streaming and checking a Server Action's request, needs an end-to-end test.
Setting up Vitest in a Next.js app
A test of a React component uses several tools, each with one job. Each row is one package, what it does, and where it runs:
| Package | What it does | Where it runs |
|---|---|---|
vitest | Finds the test files, runs them, and prints the report. | Node.js, on your computer or in CI. |
@vitejs/plugin-react | Applies React's JSX transform for Vite, which strips the TypeScript types itself. | Inside Vitest, before each file runs. |
jsdom | A page made of JavaScript: document, elements and events, with no screen. | Inside each test file, as environment: "jsdom" asks. |
@testing-library/react | render puts a component on that page; screen finds what it shows. | In each test. |
@testing-library/user-event | Clicks and types the way a person does, event by event. | In each test that does something. |
@testing-library/jest-dom | Adds matchers like toBeInTheDocument and toHaveValue. | In the setup file, once for every test. |
Next.js's Vitest guide installs vitest, @vitejs/plugin-react, jsdom, @testing-library/react and @testing-library/dom. user-event and jest-dom are the two the React course's tests also use. On October 30, 2026, npm's latest Vitest is 5.0.5, which runs on Vite 8.3.5. One command installs them as development tools:
npm install --save-dev vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom @types/node@22The last package, @types/node@22, is there for a reason. In a project create-next-app 16.4.3 just made, npm refuses to install Vitest 5.0.5 on its own, with an ERESOLVE error: Vitest asks for @types/node ^22.0.0 || >=24.0.0 as a peer dependency, and create-next-app writes "@types/node": "^20". Asking for version 22 in the same command fixes it. (The recorded projects keep create-next-app's @types/node, because the recorder links its pinned packages in itself.)
Then a config file tells Vitest how to read your files. This is the one Next.js's guide writes for TypeScript, with a setup file added, in a recorded project:
vitest.config.mtsimport { defineConfig } from "vitest/config";import react from "@vitejs/plugin-react";import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ plugins: [tsconfigPaths(), react()], test: { environment: "jsdom", setupFiles: ["./vitest.setup.ts"], },});$ npx vitest run --reporter=verboseThe plugin "vite-tsconfig-paths" is detected. Vite now supports tsconfig paths resolution natively via the resolve.tsconfigPaths option. You can remove the plugin and set resolve.tsconfigPaths: true in your Vite config instead. RUN v5.0.5 /my-app ✓ app/page.test.tsx > shows today's tea and a link to every tea ✓ app/page.test.tsx > adds Mint tea to the cart Test Files 1 passed (1) Tests 2 passed (2) $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /└ ○ /_not-found ○ (Static) prerendered as static contentIn vitest.config.mts, line 6 adds two Vite plugins. Vite itself strips the TypeScript types; react(), from @vitejs/plugin-react, applies React's JSX transform (and Fast Refresh, which only matters in a dev server). tsconfigPaths(), from line 3, reads the @/* path in tsconfig.json, so an import like @/lib/teas on line 2 of app/page.tsx works in a test as it does in Next.js. Line 8 gives every test file a jsdom page, and line 9 runs a setup file before each test file.
Open the Terminal tab. Before RUN, Vite printed: The plugin "vite-tsconfig-paths" is detected. Vite now supports tsconfig paths resolution natively via the resolve.tsconfigPaths option. The tests still passed. Vite 8.3.5 reads the paths itself, so the shop's config does without the plugin:
import { defineConfig } from "vitest/config";import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], resolve: { tsconfigPaths: true }, test: { environment: "jsdom", setupFiles: ["./vitest.setup.ts"], },}); Line 6, resolve: { tsconfigPaths: true }, takes the plugin's place. The shop's other projects use this file, and none of their reports has the message. The setup file is the same as the React course's:
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
Line 1 adds jest-dom's matchers, like toBeInTheDocument and toHaveValue, to Vitest's expect. Lines 5 to 7 call cleanup after each test, which removes what the test rendered. Testing Library only does that by itself when it finds a global afterEach, and Vitest makes none unless you set globals: true.
To run the tests, add scripts to package.json. npx vitest in a terminal keeps watching your files and runs the tests again each time you save. npx vitest run runs them once and exits with code 0 when every test passed, or 1 when one failed, which is what a CI job needs. --reporter=verbose, which the recordings add, prints a line for each test.
{
"scripts": {
"test": "vitest",
"test:ci": "vitest run"
}
}next build doesn't run tests. It does type-check every file that tsconfig.json includes, and create-next-app's include list takes every .ts, .tsx and .mts file: your tests and vitest.config.mts too. A type error in a test stops the build, as one in a page would.
Jest, with next/jest
Next.js also has a guide for Jest, the older test runner many projects use (version 30.5.2 on October 30, 2026). Its config goes through next/jest, a helper that ships with Next.js:
import type { Config } from "jest";
import nextJest from "next/jest.js";
const createJestConfig = nextJest({
// Where the app is, so next/jest can read next.config.ts and .env files.
dir: "./",
});
const config: Config = {
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
};
export default createJestConfig(config);
Line 4 makes the helper, and line 6 tells it where the app is. Line 14 passes your own settings through it. Next.js's guide lists what next/jest sets up: it turns your files into JavaScript with Next.js's own compiler (SWC), replaces stylesheets, images and next/font with stand-ins, loads your .env files and next.config, and leaves node_modules and .next out. The tests themselves look the same as Vitest's, with Jest's jest.fn() and jest.mock() in place of vi.fn() and vi.mock(). The guide gives Jest the same limit as Vitest for async Server Components. This course records Vitest, so every report on this page is Vitest's.
Client Components: render, click, check
To Vitest, "use client" on line 1 of app/add-to-cart.tsx is a string that does nothing. In the app, it marks where the client part of the page starts. In a test, the component is a function like any other, and the overview's test clicked it and read its state on the screen. A Client Component that uses only React, like AddToCart, needs nothing more.
Many Client Components in a Next.js app call something that isn't React. This form calls a Server Action, addTea, through useActionState, as Forms in Next.js showed. In a test, the action is replaced, so the test checks the form alone: what it sends, and what it shows with the answer.
app/teas/add-tea-form.test.tsximport { expect, test, vi } from "vitest";import { render, screen } from "@testing-library/react";import userEvent from "@testing-library/user-event";import { addTea } from "./actions";import AddTeaForm from "./add-tea-form"; vi.mock("./actions", () => ({ addTea: vi.fn(async () => ({ message: "Added Mint tea." })),})); test("sends the chosen tea and shows the answer", async () => { const user = userEvent.setup(); render(<AddTeaForm />); await user.selectOptions(screen.getByLabelText("Tea"), "Mint tea"); await user.click(screen.getByRole("button", { name: "Add to cart" })); expect(await screen.findByText("Added Mint tea.")).toBeInTheDocument(); expect(addTea).toHaveBeenCalledTimes(1); const [previous, formData] = vi.mocked(addTea).mock.calls[0]; expect(previous).toEqual({ message: "" }); expect(formData.get("tea")).toBe("mint");});$ npx vitest run --reporter=verbose RUN v5.0.5 /my-app ✓ app/teas/add-tea-form.test.tsx > sends the chosen tea and shows the answer Test Files 1 passed (1) Tests 1 passed (1) $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ○ /teas ○ (Static) prerendered as static contentIn app/teas/add-tea-form.test.tsx, lines 7 to 9 replace the module ./actions with one whose addTea is a mock function, made with vi.fn, that answers { message: "Added Mint tea." }. Line 4 imports that mock, not the real action. Line 14 picks Mint tea, line 15 clicks Add to cart, and line 16 waits until the answer shows. Then the test reads what the form sent: line 18 takes the first call's two arguments, the previous state and the form's FormData, line 19 checks the state, and line 20 checks the tea.
This works because React 19 runs a form's action in the browser too: useActionState calls whatever function it got. In the app, that function is Next.js's reference to the Server Action, which sends a request. In the test, it's the mock, and nothing leaves the test. The action's own code gets a test of its own, further down.
Server Components: plain ones render, async ones don't
Home rendered because it isn't async. The teas page is: it waits for getTeas(), a stand-in for a database query, before it returns.
import { getTeas } from "@/lib/teas"; export default async function TeasPage() { const teas = await getTeas(); return ( <main> <h1>Our teas</h1> <ul> {teas.map((tea) => ( <li key={tea.id}> {tea.name}: ${tea.price} </li> ))} </ul> </main> );} Line 3 makes TeasPage an async function, so line 4 can wait for the teas. In the app, Next.js renders it on the server, where React waits for it. Try both ways to test it:
app/teas/page.test.tsxNext.jsapp/teas/page.test.tsximport { expect, test } from "vitest";import { render, screen } from "@testing-library/react";import TeasPage from "./page"; test("lists every tea", async () => { render(<TeasPage />); expect(await screen.findByRole("heading", { name: "Our teas" })).toBeInTheDocument(); expect(screen.getAllByRole("listitem")).toHaveLength(3);});$ npx vitest run --reporter=verbose RUN v5.0.5 /my-app stderr | app/teas/page.test.tsx > lists every tea<TeasPage> 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 | app/teas/page.test.tsx > lists every teaA 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: await act(() => ...) × app/teas/page.test.tsx > lists every tea → Unable to find role="heading" and name "Our teas" Ignored nodes: comments, script, style<body> <div /></body> Ignored nodes: comments, script, style<body> <div /></body> ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL app/teas/page.test.tsx > lists every teaTestingLibraryElementError: Unable to find role="heading" and name "Our teas" Ignored nodes: comments, script, style<body> <div /></body> Ignored nodes: comments, script, style<body> <div /></body> ❯ waitForWrapper node_modules/@testing-library/dom/dist/wait-for.js:163:27 ❯ node_modules/@testing-library/dom/dist/query-helpers.js:86:33 ❯ app/teas/page.test.tsx:7:23 5| test("lists every tea", async () => { 6| render(<TeasPage />); 7| expect(await screen.findByRole("heading", { name: "Our teas" })).toB… | ^ 8| expect(screen.getAllByRole("listitem")).toHaveLength(3); 9| }); ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed (1) $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ○ /teas ○ (Static) prerendered as static contentLine 6 renders the page like any component. React DOM printed that <TeasPage> is an async Client Component, and findByRole on line 7 waited and found only an empty <div />. The test fails.
The first version fails. Testing Library's render renders the page with React's client renderer, React DOM, on the jsdom page, not with the renderer Next.js uses for Server Components. So React treats the async function as an async component on the client, which it doesn't support yet, and printed: <TeasPage> is an async Client Component. Only Server Components can be async at the moment. The page stayed an empty <div />, and findByRole gave up after waiting. Next.js's Vitest and Jest guides say it plainly: Vitest doesn't support async Server Components yet, and they recommend end-to-end tests for them.
The second version calls TeasPage() itself, waits for the JSX it returns, and renders that. It passed, but only because nothing below the page is async, and the page reads nothing from the request. An async component inside it would fail the same way, and a page that calls cookies() would throw, as the data access layer shows below. It also skips everything Next.js does around a page: layouts, loading.tsx, Suspense and streaming. Use it for small pages, and test the real page from the outside, as End-to-end testing does.
server-only, cookies() and the data access layer
The shop keeps one way to know who is asking: getCurrentUser in lib/current-user.ts, its data access layer. Line 1 imports server-only, so a Client Component that imports it stops the build (Server-only code showed how). Line 6 reads the session cookie with cookies(), and line 8 looks the session up in a stand-in for the sessions table. Its test calls it like any async function.
First, the package. Next.js handles import "server-only" itself, so create-next-app doesn't install it, and a Next.js app works without it. Vitest isn't Next.js: it looks for the package like any import, so a test that loads this module needs npm install server-only. Each version below has it. Pick them in order: each one fixes the error the one before it hit.
lib/current-user.tsimport "server-only";import { cookies } from "next/headers";import { findSession } from "./sessions"; export async function getCurrentUser() { const id = (await cookies()).get("session")?.value; if (!id) return null; return findSession(id);}$ npx vitest run --reporter=verbose RUN v5.0.5 /my-app ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL lib/current-user.test.ts [ lib/current-user.test.ts ]Error: This module cannot be imported from a Client Component module. It should only be used from a Server Component. ❯ Object.<anonymous> node_modules/server-only/index.js:1:7 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests no tests $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ◐ /account ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentWith server-only installed, Vitest loads the package, and the package throws as it loads: This module cannot be imported from a Client Component module. Only code built with the react-server condition, like Next.js's server code, gets its empty file.
Two things stood in the way, and each one is something only Next.js provides:
- The package throws. Its
package.jsonsends code built with thereact-servercondition, like Next.js's Server Components, to an empty file, and everything else to a file that throws. Vitest gets the throwing one, so line 6 of the setup file replaces it with an empty module, for every test. (The message names Client Components because, in a Next.js app, only their code reaches that file.) cookies()needs a request. It reads the cookies of the request being answered, and throws outside one (cookies.ts, line 146). The last version replacesnext/headerswith acookies()that reads aMap, which each test fills.
Pick the last version and open its Browser tab: the real /account, from next start, says You're not signed in. without a cookie, and Signed in as Asha with Asha's. The tests checked the same two answers through the mock. headers() works the same way: mock next/headers with each function your code calls.
A Server Action is an async function in a test
addTea, the action the tea form calls, checks who is asking, checks the tea, saves it to the cart, and updates the cart's cache. Every Server Action is a public endpoint, so the first two checks matter most (Securing Server Actions). In a test, "use server" on its line 1 is a plain string again, and addTea is an async function you can import and call with a FormData:
app/teas/actions.test.tsNext.jsapp/teas/actions.test.tsimport { beforeEach, expect, test, vi } from "vitest";import { addToCart } from "@/lib/cart";import { getCurrentUser } from "@/lib/current-user";import { addTea } from "./actions"; vi.mock("@/lib/current-user", () => ({ getCurrentUser: vi.fn() }));vi.mock("@/lib/cart", () => ({ addToCart: vi.fn() })); function teaForm(tea: string) { const formData = new FormData(); formData.set("tea", tea); return formData;} beforeEach(() => { vi.clearAllMocks();}); test("asks a visitor to sign in first", async () => { vi.mocked(getCurrentUser).mockResolvedValue(null); expect(await addTea({ message: "" }, teaForm("mint"))).toEqual({ message: "Sign in to add teas." }); expect(addToCart).not.toHaveBeenCalled();}); test("adds the tea to the signed-in user's cart", async () => { vi.mocked(getCurrentUser).mockResolvedValue({ id: "u1", name: "Asha" }); expect(await addTea({ message: "" }, teaForm("mint"))).toEqual({ message: "Added Mint tea." }); expect(addToCart).toHaveBeenCalledWith("u1", "mint");});$ npx vitest run --reporter=verbose RUN v5.0.5 /my-app ✓ app/teas/actions.test.ts > asks a visitor to sign in first × app/teas/actions.test.ts > adds the tea to the signed-in user's cart → updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL app/teas/actions.test.ts > adds the tea to the signed-in user's cartError: updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. See more info here: https://nextjs.org/docs/app/api-reference/functions/updateTag ❯ updateTag node_modules/next/src/server/web/spec-extension/revalidate.ts:64:11 ❯ Module.addTea app/teas/actions.ts:16:3 14| if (!tea) return { message: "Pick a tea from the list." }; 15| await addToCart(user.id, tea.id); 16| updateTag(`cart-${user.id}`); | ^ 17| return { message: `Added ${tea.name}.` }; 18| } ❯ app/teas/actions.test.ts:27:10 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ Test Files 1 failed (1) Tests 1 failed | 1 passed (2) $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ○ /teas ○ (Static) prerendered as static contentLines 6 and 7 mock the data access layer and the cart. The signed-out test passes. The signed-in one reaches updateTag on line 16 of actions.ts, which threw: updateTag can only be called from within a Server Action. The test called addTea itself, so no Server Action's request was around it.
Pick the second version. In its app/teas/actions.test.ts, lines 7 and 8 replace the data access layer and the cart with mock functions, so each test decides who is signed in: line 22 nobody, lines 28 and 35 Asha. teaForm, on lines 11 to 15, makes the FormData a form would send. Line 18 clears the mocks' calls before each test. Then each test checks what the action returned, and what it saved: nothing for a visitor who isn't signed in (line 24), Mint tea for Asha (line 30), and nothing for coffee, which isn't on the list (line 37).
Without line 9, the second test failed on updateTag: updateTag can only be called from within a Server Action. But addTea is a Server Action. The error says what calling it directly skips. When the browser posts the form, Next.js's action layer runs before your function:
- It finds the action by the id the form sent.
- It checks that the
Originheader matches the host, and refuses a post from another site (action-handler.ts, lines 731 to 745). - It decrypts the values a closure captured, if the action is defined inside a component.
- It runs your function inside the request, so
cookies()andupdateTagwork (revalidate.ts, lines 58 to 68).
addTea runs. A test that imports addTea calls it directly: your checks run, Next.js's don't.So a unit test of an action proves your own checks: who may call it, what it accepts, what it saves and what it returns. It can't prove that Next.js's layer works for your app: that the form posts, the origin check passes, and the page shows the answer. An end-to-end test that fills in the real form checks that.
A unit test or an end-to-end test?
Each mock stood in for something Next.js gives a component in the app. Each row is one of them: what the app gives, and what happens in a Vitest test, as the recordings showed.
| What the code uses | In the app | In a Vitest test |
|---|---|---|
The router, for useRouter, usePathname and useSearchParams | Next.js's App Router gives every page one. | There's none, so useRouter throws. Mock next/navigation. |
A request, for cookies() and headers() | Each request brings its own. | There's none, so cookies() throws. Mock next/headers. |
| React's server renderer, for async Server Components | Next.js renders them on the server. | Testing Library renders with React DOM in jsdom, which can't. |
server-only | Next.js makes it an empty module in server code. | The package throws when it loads. Mock it in the setup file. |
A Server Action's request, for updateTag | Next.js runs the action inside one. | The test calls the function directly, so updateTag throws. Mock next/cache. |
"use client" and "use server" | Mark where the client part starts, and the functions the browser may call. | Plain strings that do nothing: the component and the function just run. |
A good split for the tea shop: Vitest tests for the logic in components, actions and the data access layer, which run in a second and point at the line that broke, and a few end-to-end tests for what needs Next.js running. Sort each check:
- AddToCart's count goes up by one on each click
- The async teas page lists every tea
addTearefuses a tea that isn't on the list- A Server Action posted from another site is refused
- Choosing Price calls
router.push("/teas?sort=price") - The stock appears after its loading message
getCurrentUserreturnsnullwithout a session cookie- Clicking Teas keeps what you typed in the layout's search box
Sort each check of the tea shop: can a Vitest test with mocks check it, or does it need the real app in a browser?
Common mix-ups
- "A Next.js app needs its own test runner." No. Vitest or Jest with Testing Library test its components, as they test any React app. Next.js only adds
next/jest, a config helper for Jest. - "
next buildruns my tests." No. It type-checks them with the rest of the project and never runs them. Runvitest runyourself, in CI too. - "Server Components can't be unit-tested." Ones that aren't async can:
Homerendered like any component. Async ones can't be rendered by Testing Library yet. - "
findBywill wait for an async Server Component." No. It waited, and the page stayed an empty<div />: React DOM never rendered it. - "Mocking
next/navigationtests the navigation." No. It checks what your component asked the router to do. Whether the page changed is for an end-to-end test. - "A passing Server Action test means the action is safe." Only your own checks ran. The test called the function directly, so Next.js's action id lookup and origin check never did.
Practice exercises
These exercises are about Next.js projects and their tests, which can't run inside this page. Answer first, then use the hints.
Which line of this vitest.config.mts gives every test file a page to render into?
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: { tsconfigPaths: true },
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
Line 8, environment: "jsdom". It runs every test file with jsdom's document, so render has a page to put the component on. Without it, Vitest's default is plain Node.js, with no document.
The shop has two pages: Home in app/page.tsx, and TeasPage, shown here. Which one can a test render with render(<Page />)?
import { getTeas } from "@/lib/teas";
export default async function TeasPage() {
const teas = await getTeas();
return (
<main>
<h1>Our teas</h1>
<ul>
{teas.map((tea) => (
<li key={tea.id}>
{tea.name}: ${tea.price}
</li>
))}
</ul>
</main>
);
}
Home, in app/page.tsx. It isn't async, so React DOM renders it like any component: its recorded test passed. TeasPage, above, is async, and render(<TeasPage />) left an empty <div />.
A test mocks usePathname to return /teas and useSearchParams to return new URLSearchParams("q=mint&sort=name"). The user chooses Price. What does push receive?
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export default function SortSelect() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const sort = searchParams.get("sort") ?? "name";
function choose(next: string) {
const params = new URLSearchParams(searchParams);
params.set("sort", next);
router.push(`${pathname}?${params}`);
}
return (
<label>
Sort by{" "}
<select value={sort} onChange={(event) => choose(event.target.value)}>
<option value="name">Name</option>
<option value="price">Price</option>
</select>
</label>
);
}
/teas?q=mint&sort=price. Line 12 copies q=mint&sort=name, line 13's set("sort", "price") changes sort where it already is, and line 14 joins the path and the query.
This test fails before its first check. Which name does Vitest's error say is missing from the mock?
import { expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import SortSelect from "./sort-select";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/teas",
}));
test("starts sorted by name", () => {
render(<SortSelect />);
expect(screen.getByLabelText("Sort by")).toHaveValue("name");
});
useSearchParams. The factory returns only useRouter and usePathname, so the call on line 8 of sort-select.tsx failed: No "useSearchParams" export is defined on the "next/navigation" mock. Add useSearchParams: () => new URLSearchParams("sort=name").
The setup file now mocks server-only, and getCurrentUser's test runs with no other mocks. Which error does Vitest report?
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
// server-only throws outside Next.js's server bundles, and tests run outside them.
vi.mock("server-only", () => ({}));
afterEach(() => {
cleanup();
});
cookies was called outside a request scope. cookies() reads the request being answered, and a test has none. Mock next/headers with a cookies() the test controls.
The signed-in test fails with updateTag can only be called from within a Server Action. Which module do you mock to fix it?
import { beforeEach, expect, test, vi } from "vitest";
import { addToCart } from "@/lib/cart";
import { getCurrentUser } from "@/lib/current-user";
import { addTea } from "./actions";
vi.mock("@/lib/current-user", () => ({ getCurrentUser: vi.fn() }));
vi.mock("@/lib/cart", () => ({ addToCart: vi.fn() }));
function teaForm(tea: string) {
const formData = new FormData();
formData.set("tea", tea);
return formData;
}
beforeEach(() => {
vi.clearAllMocks();
});
test("asks a visitor to sign in first", async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null);
expect(await addTea({ message: "" }, teaForm("mint"))).toEqual({ message: "Sign in to add teas." });
expect(addToCart).not.toHaveBeenCalled();
});
test("adds the tea to the signed-in user's cart", async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ id: "u1", name: "Asha" });
expect(await addTea({ message: "" }, teaForm("mint"))).toEqual({ message: "Added Mint tea." });
expect(addToCart).toHaveBeenCalledWith("u1", "mint");
});
import { beforeEach, expect, test, vi } from "vitest";
import { updateTag } from "next/cache";
import { addToCart } from "@/lib/cart";
import { getCurrentUser } from "@/lib/current-user";
import { addTea } from "./actions";
vi.mock("@/lib/current-user", () => ({ getCurrentUser: vi.fn() }));
vi.mock("@/lib/cart", () => ({ addToCart: vi.fn() }));
vi.mock("next/cache", () => ({ updateTag: vi.fn() }));
function teaForm(tea: string) {
const formData = new FormData();
formData.set("tea", tea);
return formData;
}
beforeEach(() => {
vi.clearAllMocks();
});
test("asks a visitor to sign in first", async () => {
vi.mocked(getCurrentUser).mockResolvedValue(null);
expect(await addTea({ message: "" }, teaForm("mint"))).toEqual({ message: "Sign in to add teas." });
expect(addToCart).not.toHaveBeenCalled();
});
test("adds the tea to the signed-in user's cart", async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ id: "u1", name: "Asha" });
expect(await addTea({ message: "" }, teaForm("mint"))).toEqual({ message: "Added Mint tea." });
expect(addToCart).toHaveBeenCalledWith("u1", "mint");
expect(updateTag).toHaveBeenCalledWith("cart-u1");
});
test("refuses a tea that isn't on the list", async () => {
vi.mocked(getCurrentUser).mockResolvedValue({ id: "u1", name: "Asha" });
expect(await addTea({ message: "" }, teaForm("coffee"))).toEqual({ message: "Pick a tea from the list." });
expect(addToCart).not.toHaveBeenCalled();
});
next/cache: vi.mock("next/cache", () => ({ updateTag: vi.fn() })). The test calls addTea directly, outside the request Next.js opens for a Server Action, so the real updateTag throws. With the mock, the test can also check which tag the action updated.
Check your understanding
Question 1 of 7This test renders the async teas page. What does Testing Library find on the page?
Read the code, then predictTSXimport { expect, test } from "vitest"; import { render, screen } from "@testing-library/react"; import TeasPage from "./page"; test("lists every tea", async () => { render(<TeasPage />); expect(await screen.findByRole("heading", { name: "Our teas" })).toBeInTheDocument(); expect(screen.getAllByRole("listitem")).toHaveLength(3); });Choose an answer to see the explanation.
Question 2 of 7Why does
import "server-only"make a test file fail to load, once the package is installed?Choose an answer to see the explanation.
Question 3 of 7In the mocked sort test, why is
pushmade insidevi.hoisted?Read the code, then predictTSXimport { beforeEach, expect, test, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import SortSelect from "./sort-select"; const { push } = vi.hoisted(() => ({ push: vi.fn() })); vi.mock("next/navigation", () => ({ useRouter: () => ({ push }), usePathname: () => "/teas", useSearchParams: () => new URLSearchParams("sort=name"), })); beforeEach(() => { push.mockClear(); }); test("starts sorted by name", () => { render(<SortSelect />); expect(screen.getByLabelText("Sort by")).toHaveValue("name"); }); test("goes to the same page, sorted by price", async () => { const user = userEvent.setup(); render(<SortSelect />); await user.selectOptions(screen.getByLabelText("Sort by"), "Price"); expect(push).toHaveBeenCalledTimes(1); expect(push).toHaveBeenCalledWith("/teas?sort=price"); });Choose an answer to see the explanation.
Question 4 of 7A test calls
addTea(previous, formData)directly. What does it skip that a real form post goes through?Choose an answer to see the explanation.
Question 5 of 7Which command runs every test once and exits, the way a CI job needs?
Choose an answer to see the explanation.
Question 6 of 7Rendered without a mock,
SortSelectfails withinvariant expected app router to be mounted. What did it look for?Choose an answer to see the explanation.
Question 7 of 7Where does a test for the async teas page belong?
Choose an answer to see the explanation.
Key takeaways
- Vitest, jsdom and React Testing Library test a Next.js app's components as they test any React app. Install
@types/node@22with Vitest 5, and let Vite 8 readtsconfig.json's paths. - Client Components and Server Components that aren't async render in a test.
"use client"and"use server"do nothing there. - Async Server Components don't render with Testing Library yet. Calling the page and rendering its JSX works only for simple pages; test them end to end.
- Mock what Next.js provides:
next/navigationfor the router,next/headersfor the request,next/cacheforupdateTag, andserver-onlyin the setup file. - A Server Action is an async function in a test. Calling it checks your logic and skips Next.js's action layer, so an end-to-end test checks the real post.
Remember the one-liner.
A unit test runs your code without Next.js: mock what Next.js would give it, and test the rest in a real browser.
At release v16.4.3, useRouter throws when no App Router is mounted in navigation.ts, cookies() throws outside a request in cookies.ts, and a Server Action's request is handled by handleAction in action-handler.ts.
Coming next: End-to-end testing. Playwright drives real Chrome against next build and next start, so the async teas page, streaming, navigations and forms get tested as visitors use them.