EthicsComputerCourses
Join training +91 90532 10052

Testing Next.js Apps

Next.js · Lesson 23 of 27 Advanced 7 min read Updated

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

KindWhat it checksTypical tools
UnitOne function on its own, such as a fee calculatorVitest, Jest
ComponentOne React component: does it render and respond to clicks?Vitest or Jest with React Testing Library
IntegrationSeveral pieces togetherVitest, Jest
End-to-end (E2E)The real app in a real browser, like a userPlaywright, Cypress
SnapshotRendered output compared with a saved copyJest, 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:

React JSXTry it yourself
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>);
Output
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:

Terminal
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths
TypeScript
// 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:

React JSX
// 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) in lib/ 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:

Terminal
npm init playwright
TypeScript
// 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 build plus next start.
  • Automate tests and linting in CI, since next build no 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
JavaScript
// 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?

2. What does React Testing Library encourage?

3. Why should E2E tests run against next build and next start?

4. How do you make a Server Action's validation easy to unit 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