Testing Next.js Applications
Testing a Next.js application means covering multiple execution environments: Server Components render during the request, Client Components hydrate in the browser, route handlers process HTTP, and Server Actions mutate state across the network boundary. A strong strategy tests each layer with the right tool and the right isolation.
The testing pyramid still applies. Unit tests are fast and numerous: Server Components, Client Components, hooks, and utilities. Integration tests exercise route handlers and Server Actions against mocked dependencies. End-to-end tests with a real browser are slow but necessary for full user flows. This guide uses vitest, @testing-library/react, @testing-library/user-event, msw, and @playwright/test.
The goal is not 100% coverage for its own sake. The goal is confidence: the ability to refactor, upgrade dependencies, and ship features without breaking routing, data fetching, or auth. Tests that are slow or flaky will be ignored, so every example here prioritizes clarity, speed, and deterministic outcomes.
Vitest is the default recommendation for new Next.js projects. It supports ESM natively, has fast watch mode, and works well with Next.js path aliases. Jest is still viable for migrations, but Vitest's vi mocking utilities and ESM-first design fit the App Router better. React Testing Library is the standard for rendering, and user-event simulates realistic interactions.
| Layer | Tool | What it covers |
|---|---|---|
| Unit tests | Vitest + React Testing Library | Server Components, Client Components, hooks, utilities |
| Route handlers | node-mocks-http or MSW | GET/POST responses, error paths, auth |
| Server Actions | Vitest with mocked auth/DB | Form mutations, revalidation, redirects |
| API integration | MSW | External service calls, retry logic, errors |
| E2E | Playwright | Full browser flows, visual regression |
| 1 | # Testing stack |
| 2 | npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom |
| 3 | npm install -D node-mocks-http msw |
| 4 | npm install -D @playwright/test |
| 1 | // vitest.config.ts |
| 2 | import { defineConfig } from "vitest/config"; |
| 3 | import react from "@vitejs/plugin-react"; |
| 4 | import path from "path"; |
| 5 | |
| 6 | export default defineConfig({ |
| 7 | plugins: [react()], |
| 8 | test: { |
| 9 | environment: "jsdom", |
| 10 | globals: true, |
| 11 | setupFiles: ["./src/test/setup.ts"], |
| 12 | include: ["src/**/*.{test,spec}.{ts,tsx}"], |
| 13 | }, |
| 14 | resolve: { |
| 15 | alias: { |
| 16 | "@": path.resolve(__dirname, "./src"), |
| 17 | }, |
| 18 | }, |
| 19 | }); |
| 1 | // src/test/setup.ts |
| 2 | import { expect } from "vitest"; |
| 3 | import * as matchers from "@testing-library/jest-dom/matchers"; |
| 4 | import { cleanup } from "@testing-library/react"; |
| 5 | |
| 6 | expect.extend(matchers); |
| 7 | |
| 8 | afterEach(() => { |
| 9 | cleanup(); |
| 10 | vi.clearAllMocks(); |
| 11 | }); |
info
Server Components are async functions. Test them by awaiting the component and rendering the returned JSX. Mock Next.js imports with vi.mock hoisted to the top of the file. Target only the functions you need, such as next/headers or @/auth, and assert against the static output tree.
| 1 | // app/dashboard/page.tsx |
| 2 | import { auth } from "@/auth"; |
| 3 | import { getProjects } from "@/lib/projects"; |
| 4 | |
| 5 | export default async function DashboardPage() { |
| 6 | const session = await auth(); |
| 7 | if (!session?.user) { |
| 8 | return <p data-testid="unauthorized">Please sign in.</p>; |
| 9 | } |
| 10 | |
| 11 | const projects = await getProjects(session.user.id); |
| 12 | |
| 13 | return ( |
| 14 | <main> |
| 15 | <h1>Projects</h1> |
| 16 | <ul> |
| 17 | {projects.map((project) => ( |
| 18 | <li key={project.id}>{project.name}</li> |
| 19 | ))} |
| 20 | </ul> |
| 21 | </main> |
| 22 | ); |
| 23 | } |
| 1 | // app/dashboard/page.test.tsx |
| 2 | import { describe, it, expect, vi } from "vitest"; |
| 3 | import { render } from "@testing-library/react"; |
| 4 | import DashboardPage from "./page"; |
| 5 | |
| 6 | vi.mock("@/auth", () => ({ auth: vi.fn() })); |
| 7 | vi.mock("@/lib/projects", () => ({ getProjects: vi.fn() })); |
| 8 | |
| 9 | import { auth } from "@/auth"; |
| 10 | import { getProjects } from "@/lib/projects"; |
| 11 | |
| 12 | describe("DashboardPage", () => { |
| 13 | it("renders unauthenticated state", async () => { |
| 14 | vi.mocked(auth).mockResolvedValue(null); |
| 15 | const jsx = await DashboardPage(); |
| 16 | const { getByTestId } = render(jsx); |
| 17 | expect(getByTestId("unauthorized")).toHaveTextContent("Please sign in."); |
| 18 | }); |
| 19 | |
| 20 | it("renders projects for an authenticated user", async () => { |
| 21 | vi.mocked(auth).mockResolvedValue({ user: { id: "u_1", name: "Ada" } }); |
| 22 | vi.mocked(getProjects).mockResolvedValue([ |
| 23 | { id: "p_1", name: "Forge" }, |
| 24 | { id: "p_2", name: "Atlas" }, |
| 25 | ]); |
| 26 | |
| 27 | const jsx = await DashboardPage(); |
| 28 | const { getByText } = render(jsx); |
| 29 | |
| 30 | expect(getByText("Projects")).toBeInTheDocument(); |
| 31 | expect(getByText("Forge")).toBeInTheDocument(); |
| 32 | expect(getByText("Atlas")).toBeInTheDocument(); |
| 33 | }); |
| 34 | }); |
warning
Client Components run in jsdom. The pattern is render, query by accessible attributes, and assert behavior. Mock Next.js navigation hooks and custom hooks when the component depends on them. Return a minimal router object with only the properties the component uses.
| 1 | // components/SearchForm.tsx |
| 2 | "use client"; |
| 3 | |
| 4 | import { useState } from "react"; |
| 5 | import { useRouter } from "next/navigation"; |
| 6 | |
| 7 | export default function SearchForm() { |
| 8 | const [query, setQuery] = useState(""); |
| 9 | const router = useRouter(); |
| 10 | |
| 11 | const handleSubmit = (e: React.FormEvent) => { |
| 12 | e.preventDefault(); |
| 13 | router.push(`/search?q=${encodeURIComponent(query)}`); |
| 14 | }; |
| 15 | |
| 16 | return ( |
| 17 | <form onSubmit={handleSubmit}> |
| 18 | <label htmlFor="search">Search</label> |
| 19 | <input |
| 20 | id="search" |
| 21 | value={query} |
| 22 | onChange={(e) => setQuery(e.target.value)} |
| 23 | /> |
| 24 | <button type="submit">Go</button> |
| 25 | </form> |
| 26 | ); |
| 27 | } |
| 1 | // components/SearchForm.test.tsx |
| 2 | import { describe, it, expect, vi } from "vitest"; |
| 3 | import { render, screen } from "@testing-library/react"; |
| 4 | import userEvent from "@testing-library/user-event"; |
| 5 | import SearchForm from "./SearchForm"; |
| 6 | |
| 7 | const mockPush = vi.fn(); |
| 8 | |
| 9 | vi.mock("next/navigation", () => ({ |
| 10 | useRouter: () => ({ |
| 11 | push: mockPush, |
| 12 | replace: vi.fn(), |
| 13 | refresh: vi.fn(), |
| 14 | back: vi.fn(), |
| 15 | forward: vi.fn(), |
| 16 | prefetch: vi.fn(), |
| 17 | }), |
| 18 | usePathname: () => "/", |
| 19 | useSearchParams: () => new URLSearchParams(), |
| 20 | })); |
| 21 | |
| 22 | describe("SearchForm", () => { |
| 23 | it("navigates to search results on submit", async () => { |
| 24 | const user = userEvent.setup(); |
| 25 | render(<SearchForm />); |
| 26 | |
| 27 | const input = screen.getByLabelText("Search"); |
| 28 | await user.type(input, "nextjs testing"); |
| 29 | await user.click(screen.getByRole("button", { name: "Go" })); |
| 30 | |
| 31 | expect(mockPush).toHaveBeenCalledWith("/search?q=nextjs%20testing"); |
| 32 | }); |
| 33 | }); |
best practice
Route handlers receive a Request and return a Response. The cleanest test strategy is to call the exported GET or POST function directly with a native Request and inspect the returned Response. Mock auth and data layers so tests stay fast and deterministic.
| 1 | // app/api/projects/route.ts |
| 2 | import { NextResponse } from "next/server"; |
| 3 | import { auth } from "@/auth"; |
| 4 | import { getProjects, createProject } from "@/lib/projects"; |
| 5 | |
| 6 | export async function GET() { |
| 7 | const session = await auth(); |
| 8 | if (!session?.user) { |
| 9 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); |
| 10 | } |
| 11 | |
| 12 | const projects = await getProjects(session.user.id); |
| 13 | return NextResponse.json({ projects }); |
| 14 | } |
| 15 | |
| 16 | export async function POST(request: Request) { |
| 17 | const session = await auth(); |
| 18 | if (!session?.user) { |
| 19 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); |
| 20 | } |
| 21 | |
| 22 | const body = await request.json(); |
| 23 | if (!body.name || typeof body.name !== "string") { |
| 24 | return NextResponse.json({ error: "Invalid name" }, { status: 400 }); |
| 25 | } |
| 26 | |
| 27 | const project = await createProject(session.user.id, body.name); |
| 28 | return NextResponse.json({ project }, { status: 201 }); |
| 29 | } |
| 1 | // app/api/projects/route.test.ts |
| 2 | import { describe, it, expect, vi } from "vitest"; |
| 3 | import { GET, POST } from "./route"; |
| 4 | |
| 5 | vi.mock("@/auth", () => ({ auth: vi.fn() })); |
| 6 | vi.mock("@/lib/projects", () => ({ |
| 7 | getProjects: vi.fn(), |
| 8 | createProject: vi.fn(), |
| 9 | })); |
| 10 | |
| 11 | import { auth } from "@/auth"; |
| 12 | import { getProjects, createProject } from "@/lib/projects"; |
| 13 | |
| 14 | describe("GET /api/projects", () => { |
| 15 | it("returns 401 when unauthenticated", async () => { |
| 16 | vi.mocked(auth).mockResolvedValue(null); |
| 17 | const response = await GET(); |
| 18 | expect(response.status).toBe(401); |
| 19 | const body = await response.json(); |
| 20 | expect(body.error).toBe("Unauthorized"); |
| 21 | }); |
| 22 | |
| 23 | it("returns projects for the authenticated user", async () => { |
| 24 | vi.mocked(auth).mockResolvedValue({ user: { id: "u_1", name: "Ada" } }); |
| 25 | vi.mocked(getProjects).mockResolvedValue([{ id: "p_1", name: "Forge" }]); |
| 26 | |
| 27 | const response = await GET(); |
| 28 | expect(response.status).toBe(200); |
| 29 | |
| 30 | const body = await response.json(); |
| 31 | expect(body.projects).toHaveLength(1); |
| 32 | expect(body.projects[0].name).toBe("Forge"); |
| 33 | }); |
| 34 | }); |
| 35 | |
| 36 | describe("POST /api/projects", () => { |
| 37 | it("rejects invalid input with 400", async () => { |
| 38 | vi.mocked(auth).mockResolvedValue({ user: { id: "u_1", name: "Ada" } }); |
| 39 | |
| 40 | const request = new Request("http://localhost:3000/api/projects", { |
| 41 | method: "POST", |
| 42 | body: JSON.stringify({ name: "" }), |
| 43 | }); |
| 44 | |
| 45 | const response = await POST(request); |
| 46 | expect(response.status).toBe(400); |
| 47 | }); |
| 48 | |
| 49 | it("creates a project and returns 201", async () => { |
| 50 | vi.mocked(auth).mockResolvedValue({ user: { id: "u_1", name: "Ada" } }); |
| 51 | vi.mocked(createProject).mockResolvedValue({ |
| 52 | id: "p_2", |
| 53 | name: "Atlas", |
| 54 | ownerId: "u_1", |
| 55 | }); |
| 56 | |
| 57 | const request = new Request("http://localhost:3000/api/projects", { |
| 58 | method: "POST", |
| 59 | body: JSON.stringify({ name: "Atlas" }), |
| 60 | }); |
| 61 | |
| 62 | const response = await POST(request); |
| 63 | expect(response.status).toBe(201); |
| 64 | |
| 65 | const body = await response.json(); |
| 66 | expect(body.project.name).toBe("Atlas"); |
| 67 | }); |
| 68 | }); |
info
Server Actions run on the server in response to form submissions or direct invocation. In tests, import the action and call it directly with a FormData object or plain arguments. Mock auth, the database layer, and Next.js cache helpers such as revalidatePath. Assert both happy paths and error paths.
| 1 | // app/actions/post.ts |
| 2 | "use server"; |
| 3 | |
| 4 | import { auth } from "@/auth"; |
| 5 | import { revalidatePath } from "next/cache"; |
| 6 | import { createPost } from "@/lib/posts"; |
| 7 | |
| 8 | export async function submitPost(formData: FormData) { |
| 9 | const session = await auth(); |
| 10 | if (!session?.user) { |
| 11 | throw new Error("Unauthorized"); |
| 12 | } |
| 13 | |
| 14 | const title = formData.get("title"); |
| 15 | const content = formData.get("content"); |
| 16 | |
| 17 | if (typeof title !== "string" || typeof content !== "string") { |
| 18 | throw new Error("Invalid form data"); |
| 19 | } |
| 20 | |
| 21 | await createPost({ title, content, authorId: session.user.id }); |
| 22 | revalidatePath("/posts"); |
| 23 | } |
| 1 | // app/actions/post.test.ts |
| 2 | import { describe, it, expect, vi } from "vitest"; |
| 3 | import { submitPost } from "./post"; |
| 4 | |
| 5 | vi.mock("@/auth", () => ({ auth: vi.fn() })); |
| 6 | vi.mock("@/lib/posts", () => ({ createPost: vi.fn() })); |
| 7 | vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); |
| 8 | |
| 9 | import { auth } from "@/auth"; |
| 10 | import { createPost } from "@/lib/posts"; |
| 11 | import { revalidatePath } from "next/cache"; |
| 12 | |
| 13 | describe("submitPost", () => { |
| 14 | it("throws when the user is not authenticated", async () => { |
| 15 | vi.mocked(auth).mockResolvedValue(null); |
| 16 | |
| 17 | const formData = new FormData(); |
| 18 | formData.set("title", "Hello"); |
| 19 | formData.set("content", "World"); |
| 20 | |
| 21 | await expect(submitPost(formData)).rejects.toThrow("Unauthorized"); |
| 22 | }); |
| 23 | |
| 24 | it("creates a post and revalidates the list", async () => { |
| 25 | vi.mocked(auth).mockResolvedValue({ user: { id: "u_1", name: "Ada" } }); |
| 26 | vi.mocked(createPost).mockResolvedValue({ id: "post_1" }); |
| 27 | |
| 28 | const formData = new FormData(); |
| 29 | formData.set("title", "Hello"); |
| 30 | formData.set("content", "World"); |
| 31 | |
| 32 | await submitPost(formData); |
| 33 | |
| 34 | expect(createPost).toHaveBeenCalledWith({ |
| 35 | title: "Hello", |
| 36 | content: "World", |
| 37 | authorId: "u_1", |
| 38 | }); |
| 39 | expect(revalidatePath).toHaveBeenCalledWith("/posts"); |
| 40 | }); |
| 41 | }); |
danger
Mock Service Worker intercepts requests at the network level in Node.js and the browser. It is the most robust way to mock external HTTP APIs because it does not require changing production code. Define handlers, start the server in a setup file, and reset handlers after each test. Use server.use() to override handlers for specific error-path tests.
| 1 | // src/mocks/handlers.ts |
| 2 | import { http, HttpResponse } from "msw"; |
| 3 | |
| 4 | export const handlers = [ |
| 5 | http.get("https://api.example.com/users/:id", ({ params }) => { |
| 6 | return HttpResponse.json({ |
| 7 | id: params.id, |
| 8 | name: "Ada Lovelace", |
| 9 | email: "ada@example.com", |
| 10 | }); |
| 11 | }), |
| 12 | |
| 13 | http.post("https://api.example.com/users", async ({ request }) => { |
| 14 | const body = (await request.json()) as { name: string }; |
| 15 | |
| 16 | if (!body.name) { |
| 17 | return HttpResponse.json({ error: "Name required" }, { status: 400 }); |
| 18 | } |
| 19 | |
| 20 | return HttpResponse.json({ id: "u_99", name: body.name }, { status: 201 }); |
| 21 | }), |
| 22 | ]; |
| 1 | // src/test/setup.ts |
| 2 | import { beforeAll, afterAll, afterEach } from "vitest"; |
| 3 | import { setupServer } from "msw/node"; |
| 4 | import { handlers } from "@/mocks/handlers"; |
| 5 | |
| 6 | const server = setupServer(...handlers); |
| 7 | |
| 8 | beforeAll(() => server.listen({ onUnhandledRequest: "error" })); |
| 9 | afterEach(() => server.resetHandlers()); |
| 10 | afterAll(() => server.close()); |
| 11 | |
| 12 | // lib/users.test.ts |
| 13 | import { describe, it, expect } from "vitest"; |
| 14 | import { fetchUser } from "./users"; |
| 15 | import { server } from "@/mocks/server"; |
| 16 | import { http, HttpResponse } from "msw"; |
| 17 | |
| 18 | describe("fetchUser", () => { |
| 19 | it("returns user data on success", async () => { |
| 20 | const user = await fetchUser("u_1"); |
| 21 | expect(user.name).toBe("Ada Lovelace"); |
| 22 | }); |
| 23 | |
| 24 | it("throws on a 500 response", async () => { |
| 25 | server.use( |
| 26 | http.get("https://api.example.com/users/u_1", () => { |
| 27 | return HttpResponse.json({ error: "Server down" }, { status: 500 }); |
| 28 | }) |
| 29 | ); |
| 30 | |
| 31 | await expect(fetchUser("u_1")).rejects.toThrow(); |
| 32 | }); |
| 33 | }); |
note
Playwright is the current standard for browser-based E2E testing. It runs full Chromium, Firefox, and WebKit, provides auto-waiting selectors, and supports tracing and screenshots. For Next.js, Playwright verifies that navigation, server-rendered content, client hydration, and form submissions all work together.
| 1 | // playwright.config.ts |
| 2 | import { defineConfig, devices } from "@playwright/test"; |
| 3 | |
| 4 | export default defineConfig({ |
| 5 | testDir: "./e2e", |
| 6 | fullyParallel: true, |
| 7 | forbidOnly: !!process.env.CI, |
| 8 | retries: process.env.CI ? 2 : 0, |
| 9 | workers: process.env.CI ? 1 : undefined, |
| 10 | reporter: "html", |
| 11 | use: { |
| 12 | baseURL: "http://localhost:3000", |
| 13 | trace: "on-first-retry", |
| 14 | screenshot: "only-on-failure", |
| 15 | }, |
| 16 | projects: [ |
| 17 | { name: "chromium", use: { ...devices["Desktop Chrome"] } }, |
| 18 | { name: "firefox", use: { ...devices["Desktop Firefox"] } }, |
| 19 | { name: "webkit", use: { ...devices["Desktop Safari"] } }, |
| 20 | ], |
| 21 | webServer: { |
| 22 | command: "npm run dev", |
| 23 | url: "http://localhost:3000", |
| 24 | reuseExistingServer: !process.env.CI, |
| 25 | }, |
| 26 | }); |
| 1 | // e2e/login.spec.ts |
| 2 | import { test, expect } from "@playwright/test"; |
| 3 | import { LoginPage } from "./pages/LoginPage"; |
| 4 | |
| 5 | test("user can log in", async ({ page }) => { |
| 6 | const login = new LoginPage(page); |
| 7 | await login.goto(); |
| 8 | await login.fillEmail("ada@example.com"); |
| 9 | await login.fillPassword("password123"); |
| 10 | await login.submit(); |
| 11 | |
| 12 | await expect(page).toHaveURL("/dashboard"); |
| 13 | await expect(page.locator("h1")).toHaveText("Dashboard"); |
| 14 | }); |
| 15 | |
| 16 | // e2e/pages/LoginPage.ts |
| 17 | import { Page } from "@playwright/test"; |
| 18 | |
| 19 | export class LoginPage { |
| 20 | constructor(private page: Page) {} |
| 21 | |
| 22 | async goto() { |
| 23 | await this.page.goto("/login"); |
| 24 | } |
| 25 | |
| 26 | async fillEmail(email: string) { |
| 27 | await this.page.fill('input[name="email"]', email); |
| 28 | } |
| 29 | |
| 30 | async fillPassword(password: string) { |
| 31 | await this.page.fill('input[name="password"]', password); |
| 32 | } |
| 33 | |
| 34 | async submit() { |
| 35 | await this.page.click('button[type="submit"]'); |
| 36 | } |
| 37 | |
| 38 | async expectError(message: string) { |
| 39 | await this.page.waitForSelector(`text=${message}`); |
| 40 | } |
| 41 | } |
best practice
Auth tests must cover both authenticated and unauthenticated states. At the unit level, mock the session helper and assert that protected components, route handlers, and Server Actions reject unauthenticated requests. At the E2E level, log in through the real auth flow or restore a saved storage state so the browser has a valid session cookie.
| 1 | // app/admin/page.tsx |
| 2 | import { auth } from "@/auth"; |
| 3 | import { redirect } from "next/navigation"; |
| 4 | |
| 5 | export default async function AdminPage() { |
| 6 | const session = await auth(); |
| 7 | |
| 8 | if (!session?.user || session.user.role !== "admin") { |
| 9 | redirect("/"); |
| 10 | } |
| 11 | |
| 12 | return <h1>Admin Dashboard</h1>; |
| 13 | } |
| 14 | |
| 15 | // app/admin/page.test.tsx |
| 16 | import { describe, it, expect, vi } from "vitest"; |
| 17 | import { render } from "@testing-library/react"; |
| 18 | import AdminPage from "./page"; |
| 19 | |
| 20 | const redirect = vi.fn(); |
| 21 | |
| 22 | vi.mock("@/auth", () => ({ auth: vi.fn() })); |
| 23 | vi.mock("next/navigation", () => ({ redirect })); |
| 24 | |
| 25 | import { auth } from "@/auth"; |
| 26 | |
| 27 | describe("AdminPage", () => { |
| 28 | it("redirects non-admin users", async () => { |
| 29 | vi.mocked(auth).mockResolvedValue({ |
| 30 | user: { id: "u_1", name: "Ada", role: "member" }, |
| 31 | }); |
| 32 | |
| 33 | await AdminPage(); |
| 34 | expect(redirect).toHaveBeenCalledWith("/"); |
| 35 | }); |
| 36 | |
| 37 | it("renders for admin users", async () => { |
| 38 | vi.mocked(auth).mockResolvedValue({ |
| 39 | user: { id: "u_1", name: "Ada", role: "admin" }, |
| 40 | }); |
| 41 | |
| 42 | const jsx = await AdminPage(); |
| 43 | const { getByText } = render(jsx); |
| 44 | |
| 45 | expect(getByText("Admin Dashboard")).toBeInTheDocument(); |
| 46 | }); |
| 47 | }); |
danger
Colocate unit and integration tests with the files they exercise. This makes it obvious when a feature is missing tests and encourages refactoring. E2E tests live in a dedicated e2e/ directory because they span multiple pages. Use consistent naming such as *.test.ts or *.spec.ts.
| Location | Use case |
|---|---|
| Next to source | Unit tests for components, hooks, utilities |
| __tests__ directory | Tests for a folder of modules |
| e2e/ directory | Browser-based user journeys |
| src/mocks/ | MSW handlers and shared mocks |
| src/test/ | Vitest setup and test utilities |
| 1 | { |
| 2 | "scripts": { |
| 3 | "test": "vitest", |
| 4 | "test:ci": "vitest run --coverage", |
| 5 | "test:e2e": "playwright test", |
| 6 | "test:e2e:ui": "playwright test --ui" |
| 7 | } |
| 8 | } |
Coverage thresholds keep teams honest, but they can be gamed. Set thresholds on the lines and functions that matter, and review uncovered branches rather than chasing 100%. Flaky tests erode trust: reset mocks in afterEach, avoid shared mutable state, seed random values, and never rely on real network calls in unit tests.
pro tip
The App Router, React 19, and async Server Components introduce new error messages. The most common issues are hydration mismatches, act warnings, unresolved async utilities, and leaked mocks. Fix hydration mismatches by aligning server and client mock state, and avoid time-based values that differ between environments.
| Symptom | Likely cause | Fix |
|---|---|---|
| Hydration mismatch warning | Server and client mock state differ | Align mocks, avoid Date.now |
| Warning: not wrapped in act | State update after render without await | Use waitFor or findBy* |
| Test passes in isolation but fails in suite | Leaked mock state from another test | Reset mocks in afterEach |
| Async function did not resolve | Server Component not awaited | Call await Component() before rendering |
| MSW request not intercepted | Handler URL mismatch or server not started | Verify exact URL, check beforeAll |
| Import not found in Vitest | ESM or path alias not resolved | Configure resolve.alias in vitest.config.ts |
| 1 | import { afterEach, vi } from "vitest"; |
| 2 | |
| 3 | afterEach(() => { |
| 4 | vi.clearAllMocks(); |
| 5 | vi.unstubAllGlobals(); |
| 6 | }); |
note