A Next.js application mixes pure functions, interactive components, server-only data code and full request flows, and each layer needs a different kind of test. Vitest covers the fast, isolated tests; Playwright covers what only a real browser can verify. After this lesson you will be able to configure both tools in a Next.js project, decide which layer each test belongs to, and work around the one thing Vitest cannot do.
| Layer | Tool | What it checks | |---|---|---| | Pure logic (formatters, validators, DAL helpers) | Vitest | Inputs and outputs, no DOM | | Client Components and synchronous Server Components | Vitest + Testing Library | Rendered output and interactions in jsdom | | Route Handlers and Server Actions | Vitest | Called directly as functions | | Pages, navigation, forms, auth flows | Playwright | The real app in a real browser |
Async Server Components are the exception: Vitest cannot render async components, so their behaviour is covered by end-to-end tests.
npm install --save-dev 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" },
});vite-tsconfig-paths makes the @/ alias work in tests. Add "test": "vitest" to package.json scripts, then write a component test:
// components/Counter.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { expect, test } from "vitest";
import { Counter } from "./Counter";
test("increments on click", () => {
render(<Counter />);
fireEvent.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 1")).toBeDefined();
});Components that call useRouter or usePathname need those hooks mocked, since there is no App Router in jsdom: vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => "/" })).
Route Handlers are plain functions that take a Request, so call them without a server:
// app/api/health/route.test.ts
import { expect, test } from "vitest";
import { GET } from "./route";
test("reports ok", async () => {
const response = await GET();
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ status: "ok" });
});Server Actions can be tested the same way by passing a FormData instance, with the database module mocked via vi.mock("@/lib/db") or pointed at a test database.
npm init playwright@latestThe installer creates playwright.config.ts and an e2e folder. Point it at the dev server so tests start the app automatically:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
use: { baseURL: "http://localhost:3000" },
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});The tests in the sample at the top of this lesson use role-based locators (getByRole, getByPlaceholder), which mirror how users and assistive technology find elements and survive markup changes. Run npx playwright test locally and in CI; npx playwright test --ui opens an interactive runner for debugging.
Counter.test.tsx) and end-to-end tests in e2e/.Which kind of component can Vitest not render?
webServer starts the app; role-based locators keep tests resilient.Next lesson: Performance Optimization and Bundle Analysis ā measure Core Web Vitals and shrink what you ship.