Skip to the solution
MediumFrontend9 min read

How do you test a Next.js app (unit tests, Server Components, end-to-end)?

698 views

The problem

Question presented to candidate: "You join a team with a Next.js App Router app and zero tests. How would you set up testing: what do you unit test, how do you deal with async Server Components, and where do end-to-end tests fit?"

What a strong answer should cover:

  • The test pyramid for Next.js: unit tests (Vitest or Jest) for pure logic, Client Components, Server Actions and Route Handlers; end-to-end tests (Playwright or Cypress) for real flows against a production build.
  • Async Server Components are not supported by Vitest/React Testing Library rendering; the docs recommend E2E for them. A simple one can be called as a function (render(await Page())), but nested async children do not render.
  • Server Actions and Route Handlers are plain async functions: call them directly with a FormData or a Request, and mock next/cache, the data layer and other framework modules.
  • Proxy can be unit tested with next/experimental/testing/server helpers (matcher checks, getRedirectUrl).
  • E2E against next build + next start (Playwright webServer), not next dev, so caching, prefetching and streaming behave as in production.

Clarifying questions expected:

  • "Which flows break most or cost the most when they break?" β€” those get E2E tests first.
  • "Is there a data layer to mock, or do components query the database directly?" β€” a DAL makes unit tests much easier.

Code / implementation expected: Yes β€” a Vitest config, a component test, a Server Action or Route Handler test, and one Playwright spec.

  • nextjs
  • testing
  • vitest
  • playwright

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 8 sections

Read the code

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

export default defineConfig({
  plugins: [react()],
  resolve: { tsconfigPaths: true }, // Vite 8 resolves the "@/*" alias natively
  test: {
    environment: "jsdom",
    include: ["__tests__/**/*.test.{ts,tsx}"],
    setupFiles: ["./vitest.setup.ts"],
  },
});

// vitest.setup.ts
// Next.js server modules expect AsyncLocalStorage on globalThis, which the Next runtime normally provides.
import { AsyncLocalStorage } from "node:async_hooks";
(globalThis as unknown as { AsyncLocalStorage: typeof AsyncLocalStorage }).AsyncLocalStorage = AsyncLocalStorage;

// __tests__/home.test.tsx
import { afterEach, expect, test } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import Home from "@/app/page";

afterEach(cleanup);

test("Home renders a heading and a link", () => {
  render(<Home />);
  expect(screen.getByRole("heading", { level: 1, name: "Home" })).toBeDefined();
  expect(screen.getByRole("link", { name: "About" }).getAttribute("href")).toBe("/about");
});

test("Counter increments on click", () => {
  render(<Home />);
  const button = screen.getByRole("button");
  fireEvent.click(button);
  fireEvent.click(button);
  expect(button.textContent).toBe("Clicked 2 times");
});

// __tests__/users.test.tsx
import { afterEach, expect, test, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";

// Replace the data layer: no database (and no "server-only" module) in unit tests.
vi.mock("@/lib/data", () => ({
  getUsers: vi.fn(async () => [{ id: 1, name: "Mocked Ada" }, { id: 2, name: "Mocked Linus" }]),
}));
import UsersPage from "@/app/users/page";

afterEach(cleanup);

test("async Server Component: await it as a function, then render the result", async () => {
  const ui = await UsersPage(); // a Server Component is just an async function returning JSX
  render(ui);
  expect(screen.getByRole("heading").textContent).toBe("Users (2)");
  expect(screen.getAllByRole("listitem").map((li) => li.textContent)).toEqual(["Mocked Ada", "Mocked Linus"]);
});

// __tests__/actions.test.ts
// @vitest-environment node
import { expect, test, vi } from "vitest";

vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { revalidatePath } from "next/cache";
import { createTodo } from "@/app/actions";

const form = (title: string) => { const f = new FormData(); f.set("title", title); return f; };

test("rejects short titles without revalidating", async () => {
  expect(await createTodo({ ok: false }, form("ab"))).toEqual({ ok: false, error: "Title must be at least 3 characters" });
  expect(revalidatePath).not.toHaveBeenCalled();
});

test("accepts a valid title and revalidates /todos", async () => {
  expect(await createTodo({ ok: false }, form("Buy milk"))).toEqual({ ok: true });
  expect(revalidatePath).toHaveBeenCalledWith("/todos");
});

// __tests__/route.test.ts
// @vitest-environment node
import { expect, test } from "vitest";
import { GET } from "@/app/api/health/route";

test("GET /api/health", async () => {
  const res = await GET(new Request("http://localhost/api/health?verbose=1"));
  expect(res.status).toBe(200);
  expect(await res.json()).toEqual({ status: "ok", uptime: "lab" });
});

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

test("matcher runs proxy only under /dashboard", () => {
  expect(testing.unstable_doesMiddlewareMatch({ config, url: "/dashboard/settings" })).toBe(true);
  expect(testing.unstable_doesMiddlewareMatch({ config, url: "/about" })).toBe(false);
});

test("the name used in the 16.4.2 proxy docs is not exported by the package", () => {
  expect("unstable_doesProxyMatch" in testing).toBe(false);
});

test("redirects to /login without a session cookie", () => {
  const res = proxy(new NextRequest("http://localhost/dashboard"));
  expect(testing.getRedirectUrl(res)).toBe("http://localhost/login");
});

test("lets the request through with a session cookie", () => {
  const res = proxy(new NextRequest("http://localhost/dashboard", { headers: { cookie: "session=abc" } }));
  expect(testing.getRedirectUrl(res)).toBeNull();
  expect(res.headers.get("x-middleware-next")).toBe("1");
});
Playwright against the production build
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  use: {
    baseURL: "http://localhost:4163",
    // lab only: reuse the preinstalled Chromium instead of downloading one
    launchOptions: { executablePath: process.env.CHROMIUM_PATH },
  },
  webServer: {
    command: "npx next start -p 4163", // test the production build, not next dev
    url: "http://localhost:4163",
    reuseExistingServer: !process.env.CI,
  },
});

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

test("navigates from Home to About with a client-side transition", async ({ page }) => {
  await page.goto("/");
  await page.evaluate(() => ((window as unknown as { marker: number }).marker = 42));
  await page.getByRole("link", { name: "About" }).click();
  await expect(page).toHaveURL("/about");
  await expect(page.getByRole("heading", { level: 1 })).toHaveText("About");
  // same document: the marker survived, so it was not a full page load
  expect(await page.evaluate(() => (window as unknown as { marker?: number }).marker)).toBe(42);
});

test("async Server Component renders real data", async ({ page }) => {
  await page.goto("/users");
  await expect(page.getByRole("heading")).toHaveText("Users (2)");
  await expect(page.getByRole("listitem")).toHaveText(["Ada", "Linus"]);
});

test("client component is interactive after hydration", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("button").click();
  await expect(page.getByRole("button")).toHaveText("Clicked 1 times");
});

test("proxy redirects /dashboard without a session", async ({ page }) => {
  const res = await page.request.get("/dashboard", { maxRedirects: 0 });
  expect(res.status()).toBe(307);
  expect(res.headers()["location"]).toBe("/login");
});

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 56 of 129 in Next.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Next.js questions
Up nextMedium

What is the difference between revalidateTag(), updateTag(), and refresh() in Next.js 16?

Continue