Testing Next.js Apps
Every change you make can break something you did not touch. Automated tests catch those mistakes in seconds instead of letting users find them. A Next.js app has several kinds of code (pure functions, Client Components, Server Components, Server Actions, Proxy) and they are best tested in slightly different ways.
In this lesson you will learn the main kinds of tests, set up Vitest for unit and component tests, write an end-to-end test with Playwright, and choose a strategy for Server Components and Server Actions.
Kinds of tests
| Kind | What it checks | Typical tools |
|---|---|---|
| Unit | One function on its own, such as a fee calculator | Vitest, Jest |
| Component | One React component: does it render and respond to clicks? | Vitest or Jest with React Testing Library |
| Integration | Several pieces together | Vitest, Jest |
| End-to-end (E2E) | The real app in a real browser, like a user | Playwright, Cypress |
| Snapshot | Rendered output compared with a saved copy | Jest, Vitest |
The Next.js docs include setup guides for Vitest, Jest, Playwright and Cypress. A healthy project has many fast unit tests, some component tests, and a few E2E tests for the most important journeys, such as "a student can enrol in a course".
The idea of a test, runnable in the browser
Every test runner works the same way: run some code, compare the result with what you expect, and report pass or fail. This runnable example builds a tiny runner with test and expect so you can see the idea before installing real tools. It tests a pure function and a small component rendered into a detached DOM element:
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
const results = [];
function test(name, fn) {
try { fn(); results.push("PASS " + name); } catch (e) { results.push("FAIL " + name + ": " + e.message); }
}
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
},
};
}
// Code under test
function finalFee(fee, siblings) {
const discount = siblings > 0 ? 0.1 : 0;
return Math.round(fee * (1 - discount));
}
function FeeBadge({ fee }) {
return <span>{fee === 0 ? "Free" : `₹${fee}`}</span>;
}
test("no discount without siblings", () => expect(finalFee(1499, 0)).toBe(1499));
test("10% sibling discount", () => expect(finalFee(1499, 1)).toBe(1349));
test("FeeBadge shows Free for 0", () => {
const box = document.createElement("div");
flushSync(() => createRoot(box).render(<FeeBadge fee={0} />));
expect(box.textContent).toBe("Free");
});
test("FeeBadge shows rupees", () => {
const box = document.createElement("div");
flushSync(() => createRoot(box).render(<FeeBadge fee={999} />));
expect(box.textContent).toBe("₹ 999");
});
results.forEach((r) => console.log(r));
createRoot(document.getElementById("root")).render(<pre>{results.join("\n")}</pre>);PASS no discount without siblings PASS 10% sibling discount PASS FeeBadge shows Free for 0 FAIL FeeBadge shows rupees: expected "₹ 999", got "₹999"
The last test fails on purpose: the expected text has a space that the component does not render. A failing test with a clear message is exactly what you want when something is wrong. Fix the expectation to "₹999" and run it again.
Unit and component tests with Vitest
Vitest is a fast test runner that understands modern JavaScript and JSX. Install it with React Testing Library and a browser-like environment:
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths// vitest.config.mts
import { 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" },
});Add "test": "vitest" to the scripts in package.json. A component test renders a Client Component and checks what the user would see:
// app/_components/Counter.test.jsx
import { render, screen, fireEvent } from "@testing-library/react";
import { expect, test } from "vitest";
import LikeButton from "./LikeButton";
test("LikeButton adds one like per click", () => {
render(<LikeButton initialLikes={3} />);
fireEvent.click(screen.getByRole("button"));
expect(screen.getByRole("button").textContent).toContain("4");
});Run npm test and Vitest reports one test file and one test passed, then keeps watching your files and re-runs the tests when you save. Testing Library encourages finding elements the way users do, by role and visible text, rather than by CSS classes.
Server Components and Server Actions
The docs point out that async Server Components are new to the React ecosystem, and tools such as Vitest and Jest do not fully support rendering them. Their recommendation: cover async components with end-to-end tests. Two habits make the rest easy to unit test:
- Move logic into plain functions. Instead of validating inside the action, write
validateEnquiry(data)inlib/and call it from the action. The plain function is trivial to unit test. - Keep data access in the DAL. Test DAL functions with a test database, separately from the UI.
For Proxy, Next.js has experimental helpers in next/experimental/testing/server, such as unstable_doesProxyMatch, to check whether your matcher runs for a given URL.
End-to-end tests with Playwright
Playwright drives real browsers (Chromium, Firefox and WebKit). Set it up with its own initialiser, then write tests that click through your app:
npm init playwright// e2e/enquiry.spec.ts
import { test, expect } from "@playwright/test";
test("visitor can send an enquiry", async ({ page }) => {
await page.goto("http://localhost:3000/");
await page.getByRole("link", { name: "Contact" }).click();
await expect(page).toHaveURL(/\/contact/);
await page.getByPlaceholder("Name").fill("Meera Nair");
await page.getByPlaceholder("Mobile").fill("9876543210");
await page.getByRole("button", { name: "Send enquiry" }).click();
await expect(page.getByText("Thanks Meera Nair")).toBeVisible();
});Run E2E tests against the production build (npm run build then npm run start), because that is what users get; Playwright's webServer option in playwright.config.ts can start the server for you. E2E tests are slower, so keep them for the journeys that matter most: login, enrolment, payment.
Common mistakes
- Only E2E tests. They are slow and hard to debug. Push logic into functions with fast unit tests.
- Testing implementation details. Checking a component's internal state breaks on every refactor. Test what the user sees.
- Trying to unit test async Server Components. Use E2E tests for them, as the docs recommend.
- E2E tests against
next dev. Development mode behaves differently (no prefetching, no caching). Test the production build.
Key points
- Use unit and component tests (Vitest or Jest with Testing Library) for functions and Client Components.
- Use E2E tests (Playwright or Cypress) for async Server Components and complete user journeys.
- Move validation and business rules out of actions into plain functions that are easy to test.
- Run E2E tests against
next buildplusnext start. - Automate tests and linting in CI, since
next buildno longer lints.
Exercise
Write a plain function validateEnquiry({ name, phone }) that returns an object of error messages (empty when valid), plus three Vitest tests: a valid enquiry, a short name, and a phone number with 9 digits. Then describe in two sentences which E2E test you would add for the enquiry form.
Show solution
// lib/validate-enquiry.js
export function validateEnquiry({ name = "", phone = "" }) {
const errors = {};
if (name.trim().length < 2) errors.name = "Please enter your full name.";
if (!/^[6-9]\d{9}$/.test(phone.trim())) errors.phone = "Enter a 10-digit Indian mobile number.";
return errors;
}
// lib/validate-enquiry.test.js
import { expect, test } from "vitest";
import { validateEnquiry } from "./validate-enquiry";
test("valid enquiry has no errors", () => {
expect(validateEnquiry({ name: "Farhan Ali", phone: "9123456789" })).toEqual({});
});
test("short name is rejected", () => {
expect(validateEnquiry({ name: "F", phone: "9123456789" }).name).toBeDefined();
});
test("9-digit phone is rejected", () => {
expect(validateEnquiry({ name: "Farhan Ali", phone: "912345678" }).phone).toBeDefined();
});The E2E test would open /contact, fill the form with valid values, submit it and expect the thank-you message. A second E2E test could submit an empty form and expect both error messages to be visible.
Quick Quiz
1. Which kind of test is recommended for async Server Components?
Unit test tools do not fully support async Server Components yet, so the docs recommend E2E tests.
2. What does React Testing Library encourage?
Tests that behave like users survive refactoring better.
3. Why should E2E tests run against next build and next start?
Prefetching, caching and static rendering only happen in production builds.
4. How do you make a Server Action's validation easy to unit test?
Plain functions run anywhere and are quick to test.
Learn Next.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
