Vitest and React Testing Library with Next.js 15: The Complete Unit Testing Guide for 2026

Test with confidence. Vitest is the blazing-fast testing framework powered by Vite that has replaced Jest in most modern projects by 2026. Combined with React Testing Library, it delivers an intuitive, fast, and reliable testing experience. In this tutorial, you will learn to test every layer of your Next.js 15 application.
What You Will Learn
By the end of this tutorial, you will:
- Set up Vitest in a Next.js 15 App Router project
- Write component tests with React Testing Library
- Test custom hooks with
renderHook - Mock Server Components and Server Actions
- Test API Routes (Route Handlers)
- Measure and improve code coverage
- Integrate tests into a CI/CD pipeline
- Apply best practices for maintainable tests
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - Experience with TypeScript and React
- Familiarity with Next.js 15 (App Router, Server Components)
- A code editor — VS Code or Cursor recommended
- Basic understanding of software testing (assertions, mocks)
Why Vitest Over Jest?
In 2026, Vitest has become the standard for testing in the JavaScript ecosystem. Here is why:
| Criteria | Jest | Vitest |
|---|---|---|
| Speed | Slow (CJS transformation) | Blazing fast (native ESM via Vite) |
| Configuration | Complex with Next.js | Minimal with next/vitest |
| ESM | Partial support | Full native support |
| Hot reload | No | Intelligent watch mode |
| API | Proprietary | Jest-compatible (easy migration) |
| TypeScript | Requires ts-jest | Native support |
Vitest's API is intentionally compatible with Jest, meaning if you know Jest, you already know Vitest.
Step 1: Initialize the Next.js Project
Create a new Next.js 15 project:
npx create-next-app@latest my-tested-app --typescript --tailwind --app --src-dir
cd my-tested-appVerify everything works:
npm run devStep 2: Install Vitest and React Testing Library
Install the testing dependencies:
npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomHere is what each package does:
| Package | Purpose |
|---|---|
vitest | Testing framework |
@vitejs/plugin-react | JSX/TSX support in Vitest |
@testing-library/react | Utilities for testing React components |
@testing-library/jest-dom | Custom DOM matchers (toBeInTheDocument, etc.) |
@testing-library/user-event | Realistic user interaction simulation |
jsdom | DOM environment for Node.js |
Step 3: Configure Vitest
Create vitest.config.ts at the project root:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.{test,spec}.{ts,tsx}",
"src/**/*.d.ts",
"src/test/**",
],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});Create the setup file src/test/setup.ts:
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// Automatically clean up after each test
afterEach(() => {
cleanup();
});Add test scripts to package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}For the interactive UI mode (optional but very useful):
npm install -D @vitest/uiStep 4: Configure TypeScript for Tests
Add Vitest types to tsconfig.json:
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
}
}This allows using describe, it, expect, and jest-dom matchers without explicit imports.
Step 5: Your First Component Test
Let's create a simple component to test. Create src/components/Counter.tsx:
"use client";
import { useState } from "react";
interface CounterProps {
initialCount?: number;
step?: number;
}
export function Counter({ initialCount = 0, step = 1 }: CounterProps) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p data-testid="count-display">Count: {count}</p>
<button onClick={() => setCount((c) => c + step)}>Increment</button>
<button onClick={() => setCount((c) => c - step)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}Now write the test in src/components/__tests__/Counter.test.tsx:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "../Counter";
describe("Counter", () => {
it("displays the initial count as 0", () => {
render(<Counter />);
expect(screen.getByTestId("count-display")).toHaveTextContent("Count: 0");
});
it("accepts a custom initial value", () => {
render(<Counter initialCount={10} />);
expect(screen.getByTestId("count-display")).toHaveTextContent("Count: 10");
});
it("increments the counter on click", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByText("Increment"));
expect(screen.getByTestId("count-display")).toHaveTextContent("Count: 1");
});
it("decrements the counter on click", async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
await user.click(screen.getByText("Decrement"));
expect(screen.getByTestId("count-display")).toHaveTextContent("Count: 4");
});
it("resets the counter to zero", async () => {
const user = userEvent.setup();
render(<Counter initialCount={42} />);
await user.click(screen.getByText("Reset"));
expect(screen.getByTestId("count-display")).toHaveTextContent("Count: 0");
});
it("uses custom step size", async () => {
const user = userEvent.setup();
render(<Counter step={5} />);
await user.click(screen.getByText("Increment"));
expect(screen.getByTestId("count-display")).toHaveTextContent("Count: 5");
await user.click(screen.getByText("Increment"));
expect(screen.getByTestId("count-display")).toHaveTextContent("Count: 10");
});
});Run the test:
npm testYou should see all tests passing in green.
Step 6: Testing Form Components
Forms are at the core of most applications. Create src/components/LoginForm.tsx:
"use client";
import { useState } from "react";
interface LoginFormProps {
onSubmit: (data: { email: string; password: string }) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!email.includes("@")) {
setError("Invalid email address");
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setIsLoading(true);
try {
await onSubmit({ email, password });
} catch (err) {
setError("Login failed. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} aria-label="Login form">
{error && (
<div role="alert" className="text-red-500">
{error}
</div>
)}
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
);
}Test it in src/components/__tests__/LoginForm.test.tsx:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { LoginForm } from "../LoginForm";
describe("LoginForm", () => {
const mockOnSubmit = vi.fn();
beforeEach(() => {
mockOnSubmit.mockReset();
});
it("renders the form with all fields", () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
expect(screen.getByText("Sign in")).toBeInTheDocument();
});
it("shows error for invalid email", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText("Email"), "invalid-email");
await user.type(screen.getByLabelText("Password"), "password123");
await user.click(screen.getByText("Sign in"));
expect(screen.getByRole("alert")).toHaveTextContent(
"Invalid email address"
);
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it("shows error for short password", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.type(screen.getByLabelText("Password"), "short");
await user.click(screen.getByText("Sign in"));
expect(screen.getByRole("alert")).toHaveTextContent(
"Password must be at least 8 characters"
);
});
it("submits form with valid data", async () => {
mockOnSubmit.mockResolvedValue(undefined);
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.type(screen.getByLabelText("Password"), "password123");
await user.click(screen.getByText("Sign in"));
expect(mockOnSubmit).toHaveBeenCalledWith({
email: "user@example.com",
password: "password123",
});
});
it("disables button while loading", async () => {
mockOnSubmit.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 1000))
);
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.type(screen.getByLabelText("Password"), "password123");
await user.click(screen.getByText("Sign in"));
expect(screen.getByText("Signing in...")).toBeDisabled();
});
it("shows error on submission failure", async () => {
mockOnSubmit.mockRejectedValue(new Error("Network error"));
const user = userEvent.setup();
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.type(screen.getByLabelText("Password"), "password123");
await user.click(screen.getByText("Sign in"));
expect(
await screen.findByText("Login failed. Please try again.")
).toBeInTheDocument();
});
});Step 7: Testing Custom Hooks
React Testing Library provides renderHook for testing hooks in isolation. Create src/hooks/useLocalStorage.ts:
"use client";
import { useState, useEffect } from "react";
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch {
console.error(`Error saving "${key}" to localStorage`);
}
}, [key, storedValue]);
return [storedValue, setStoredValue] as const;
}Test it in src/hooks/__tests__/useLocalStorage.test.ts:
import { renderHook, act } from "@testing-library/react";
import { useLocalStorage } from "../useLocalStorage";
describe("useLocalStorage", () => {
beforeEach(() => {
localStorage.clear();
});
it("returns initial value when localStorage is empty", () => {
const { result } = renderHook(() => useLocalStorage("theme", "light"));
expect(result.current[0]).toBe("light");
});
it("reads existing value from localStorage", () => {
localStorage.setItem("theme", JSON.stringify("dark"));
const { result } = renderHook(() => useLocalStorage("theme", "light"));
expect(result.current[0]).toBe("dark");
});
it("updates value in localStorage", () => {
const { result } = renderHook(() => useLocalStorage("count", 0));
act(() => {
result.current[1](42);
});
expect(result.current[0]).toBe(42);
expect(JSON.parse(localStorage.getItem("count")!)).toBe(42);
});
it("handles complex objects", () => {
const initial = { name: "John", age: 30 };
const { result } = renderHook(() => useLocalStorage("user", initial));
act(() => {
result.current[1]({ name: "Jane", age: 25 });
});
expect(result.current[0]).toEqual({ name: "Jane", age: 25 });
});
});Step 8: Mocking Modules and APIs
Vitest offers a powerful mocking system. Here are the most common techniques.
Mocking an entire module
import { vi } from "vitest";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/",
}));Mocking fetch calls
Create src/lib/api.ts:
export async function fetchUsers() {
const response = await fetch("/api/users");
if (!response.ok) throw new Error("Failed to fetch");
return response.json();
}Test with a fetch mock:
import { vi } from "vitest";
import { fetchUsers } from "../api";
describe("fetchUsers", () => {
it("returns users", async () => {
const mockUsers = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUsers),
});
const users = await fetchUsers();
expect(users).toEqual(mockUsers);
expect(fetch).toHaveBeenCalledWith("/api/users");
});
it("throws on failure", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
await expect(fetchUsers()).rejects.toThrow("Failed to fetch");
});
});Mocking with vi.spyOn
import * as api from "../api";
it("spies on a function call", async () => {
const spy = vi.spyOn(api, "fetchUsers").mockResolvedValue([]);
// ... your code that calls fetchUsers
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});Step 9: Testing Components with Async Data
Create a component that fetches data. src/components/UserList.tsx:
"use client";
import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
email: string;
}
export function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/users")
.then((res) => {
if (!res.ok) throw new Error("Server error");
return res.json();
})
.then(setUsers)
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <p>Loading...</p>;
if (error) return <p role="alert">Error: {error}</p>;
return (
<ul aria-label="User list">
{users.map((user) => (
<li key={user.id}>
<strong>{user.name}</strong> — {user.email}
</li>
))}
</ul>
);
}Test it:
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { UserList } from "../UserList";
describe("UserList", () => {
it("shows loading state", () => {
global.fetch = vi.fn().mockImplementation(
() => new Promise(() => {}) // Promise that never resolves
);
render(<UserList />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("displays the list of users", async () => {
const mockUsers = [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUsers),
});
render(<UserList />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByRole("list")).toHaveAttribute("aria-label", "User list");
});
it("shows error message on failure", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
render(<UserList />);
expect(
await screen.findByText("Error: Server error")
).toBeInTheDocument();
});
});Tip: Use findByText instead of getByText for elements that appear asynchronously. findByText automatically waits for the element to be present in the DOM.
Step 10: Testing API Routes (Route Handlers)
Next.js 15 uses Route Handlers in the app/api/ directory. Create src/app/api/users/route.ts:
import { NextRequest, NextResponse } from "next/server";
const users = [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
];
export async function GET() {
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.name || !body.email) {
return NextResponse.json(
{ error: "Name and email are required" },
{ status: 400 }
);
}
const newUser = {
id: users.length + 1,
name: body.name,
email: body.email,
};
return NextResponse.json(newUser, { status: 201 });
}Test the Route Handlers directly:
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
describe("API /api/users", () => {
describe("GET", () => {
it("returns the list of users", async () => {
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(2);
expect(data[0]).toHaveProperty("name", "Alice");
});
});
describe("POST", () => {
it("creates a new user", async () => {
const request = new NextRequest("http://localhost/api/users", {
method: "POST",
body: JSON.stringify({ name: "Charlie", email: "charlie@example.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data).toMatchObject({
name: "Charlie",
email: "charlie@example.com",
});
});
it("returns 400 if data is incomplete", async () => {
const request = new NextRequest("http://localhost/api/users", {
method: "POST",
body: JSON.stringify({ name: "Charlie" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Name and email are required");
});
});
});Step 11: Testing with Providers (Context, Theme, etc.)
Most applications use React providers. Create a custom render utility:
// src/test/test-utils.tsx
import { ReactElement } from "react";
import { render, RenderOptions } from "@testing-library/react";
function AllProviders({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function customRender(
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">
) {
return render(ui, { wrapper: AllProviders, ...options });
}
export * from "@testing-library/react";
export { customRender as render };Use it in your tests:
// Instead of:
import { render, screen } from "@testing-library/react";
// Use:
import { render, screen } from "@/test/test-utils";Step 12: Snapshots and Visual Testing
Vitest supports snapshot testing to detect unexpected changes:
import { render } from "@testing-library/react";
import { Counter } from "../Counter";
it("matches snapshot", () => {
const { container } = render(<Counter initialCount={5} />);
expect(container).toMatchSnapshot();
});
// Or with inline snapshots (more readable)
it("matches inline snapshot", () => {
const { container } = render(<Counter />);
expect(container.firstChild).toMatchInlineSnapshot(`
<div>
<p data-testid="count-display">Count: 0</p>
<button>Increment</button>
<button>Decrement</button>
<button>Reset</button>
</div>
`);
});Warning: Snapshots are useful for detecting regressions, but do not use them as a substitute for explicit assertions. Prefer toHaveTextContent and toBeInTheDocument to verify behavior.
Step 13: Code Coverage
Run tests with coverage:
npm run test:coverageVitest generates a detailed coverage report:
-----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------|---------|----------|---------|---------|
All files | 92.3 | 85.7 | 100 | 92.3 |
components/Counter | 100 | 100 | 100 | 100 |
components/LoginForm | 95.2 | 83.3 | 100 | 95.2 |
hooks/useLocalStorage | 88.9 | 75.0 | 100 | 88.9 |
-----------------------|---------|----------|---------|---------|
Configure coverage thresholds
Add minimum thresholds in vitest.config.ts:
coverage: {
provider: "v8",
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},If coverage falls below these thresholds, tests will fail in CI.
Step 14: CI/CD Integration with GitHub Actions
Create .github/workflows/test.yml:
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test:run
- name: Check coverage
run: npm run test:coverageStep 15: Best Practices and Advanced Patterns
1. Follow Testing Library Philosophy
"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds
// Bad: testing implementation details
expect(component.state.isOpen).toBe(true);
// Good: testing visible behavior
expect(screen.getByRole("dialog")).toBeVisible();2. Prefer Role Queries
// Fragile: depends on exact text
screen.getByText("Submit");
// Robust: depends on accessible role
screen.getByRole("button", { name: /submit/i });3. Use userEvent Instead of fireEvent
// fireEvent is low-level
fireEvent.click(button);
// userEvent simulates real user behavior
const user = userEvent.setup();
await user.click(button);4. Organize with the AAA Pattern
it("filters users by name", async () => {
// Arrange
const user = userEvent.setup();
render(<UserSearch users={mockUsers} />);
// Act
await user.type(screen.getByRole("searchbox"), "Alice");
// Assert
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.queryByText("Bob")).not.toBeInTheDocument();
});5. Avoid Flaky Tests
// Fragile timing
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(screen.getByText("Loaded")).toBeInTheDocument();
// Assertion-based waiting
expect(await screen.findByText("Loaded")).toBeInTheDocument();6. Clean Up Mocks
afterEach(() => {
vi.restoreAllMocks();
});Troubleshooting
"ReferenceError: document is not defined"
Verify the environment is set to jsdom in vitest.config.ts:
test: {
environment: "jsdom",
}"Cannot find module '@/...'"
Make sure aliases are configured in vitest.config.ts:
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},Tests are slow
- Use
--reporter=verboseto identify slow tests - Avoid
setTimeoutin tests - Use
vi.useFakeTimers()for components with timers - Run tests in parallel (Vitest default behavior)
Mock not working
Ensure vi.mock() is called at the module level (not inside describe or it):
// Correct: at module level
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
// Incorrect: inside a test block
describe("MyComponent", () => {
vi.mock("next/navigation"); // Too late!
});Next Steps
Now that your unit tests are in place:
- Explore E2E testing with Playwright to complete your testing strategy — check our Playwright tutorial
- Add visual snapshot testing with Chromatic or Percy
- Set up mutation testing with Stryker to validate test quality
- Explore Vitest Browser Mode for testing in a real browser
Conclusion
You now have a complete testing setup for your Next.js 15 application:
- Vitest configured with React Testing Library for fast, reliable tests
- Tests for components, hooks, forms, and API routes
- Mocking mastered for modules, fetch, and providers
- Code coverage measured with configurable thresholds
- A CI/CD pipeline ready for production
Tests are not a chore — they are your safety net. Every test you write is a living specification of your application, a guardian against regressions, and documentation that never lies. Invest in your tests today, and your future self will thank you.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

End-to-End Testing with Playwright and Next.js: From Zero to CI Pipeline
Learn how to set up Playwright for end-to-end testing in a Next.js application. This hands-on tutorial covers setup, Page Object Model, visual regression, accessibility testing, and CI/CD integration with GitHub Actions.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.

Better Auth with Next.js 15: The Complete Authentication Guide for 2026
Learn how to implement full-featured authentication in Next.js 15 using Better Auth. This tutorial covers email/password, OAuth, sessions, middleware protection, and role-based access control.