Mocking in Tests
Mock modules, functions, APIs, and Next.js internals to isolate the code under test.
Search across all documentation pages
Mock modules, functions, APIs, and Next.js internals to isolate the code under test.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card -- copy-paste ready.
import { vi } from "vitest";
// Mock a module
vi.mock("@/lib/analytics", () => ({
trackEvent: vi.fn(),
}));
// Mock a function
const onSubmit = vi.fn();
onSubmit.mockResolvedValueOnce({ success: true });
// Mock fetch
vi.stubGlobal("fetch", vi.fn());
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ data: [] }), { status: 200 })
);
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/dashboard",
useSearchParams: () => new URLSearchParams("?tab=settings"),
}));
// Mock next/image
vi.mock("next/image", () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img {...props} />
),
}));When to reach for this: When your component depends on external modules, API calls, or Next.js internals that you need to control in tests.
// src/components/user-profile.tsx
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface User {
id: number;
name: string;
email: string;
}
export function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
async function loadUser() {
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error("Failed to load user");
setUser(await res.json());
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}
loadUser();
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p role="alert">{error}</p>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<button onClick={() => router.push(`/users/${userId}/edit`)}>
Edit Profile
</button>
</div>
);
}// src/components/user-profile.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { UserProfile } from "./user-profile";
// Mock next/navigation
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
back: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/users/1",
useSearchParams: () => new URLSearchParams(),
}));
describe("UserProfile", () => {
const user = userEvent.setup();
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
mockPush.mockClear();
});
it("shows loading state initially", () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ id: 1, name: "Alice", email: "alice@test.com" }))
);
render(<UserProfile userId={1} />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("renders user data on success", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ id: 1, name: "Alice", email: "alice@test.com" }))
);
render(<UserProfile userId={1} />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(screen.getByText("alice@test.com")).toBeInTheDocument();
});
it("shows error on fetch failure", async () => {
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 }));
render(<UserProfile userId={1} />);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Failed to load user"
);
});
it("navigates to edit page on button click", async () => {
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify({ id: 1, name: "Alice", email: "alice@test.com" }))
);
render(<UserProfile userId={1} />);
await screen.findByText("Alice");
await user.click(screen.getByRole("button", { name: /edit profile/i }));
expect(mockPush).toHaveBeenCalledWith("/users/1/edit");
});
});What this demonstrates:
next/navigationfetch with vi.stubGlobalmockPush for assertionvi.mock() replaces an entire module at the top of the file -- it is hoisted above imports by Vitest's transformvi.fn() creates a mock function that tracks calls, arguments, and return valuesvi.mocked() is a type helper that casts a function to its mocked type, enabling auto-completion for mock methodsvi.stubGlobal() replaces a global like fetch and restores it when you call vi.restoreAllMocks()vi.resetModules() between testsMSW (Mock Service Worker) for API mocking:
npm install -D msw// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@test.com",
});
}),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 99, ...body }, { status: 201 });
}),
http.get("/api/users/:id", () => {
return HttpResponse.json(
{ message: "Not found" },
{ status: 404 }
);
}),
];// src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);// vitest.setup.ts
import { server } from "./src/mocks/server";
import { beforeAll, afterAll, afterEach } from "vitest";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());// Test using MSW -- no manual fetch mock needed
import { http, HttpResponse } from "msw";
import { server } from "@/mocks/server";
it("handles 404", async () => {
// Override for this specific test
server.use(
http.get("/api/users/:id", () => {
return HttpResponse.json({ message: "Not found" }, { status: 404 });
})
);
render(<UserProfile userId={999} />);
expect(await screen.findByRole("alert")).toBeInTheDocument();
});Mocking with Jest (same patterns, different API):
// jest.mock equivalent
jest.mock("next/navigation", () => ({
useRouter: () => ({ push: jest.fn() }),
usePathname: () => "/dashboard",
useSearchParams: () => new URLSearchParams(),
}));
// jest.fn equivalent
const handleSubmit = jest.fn();
handleSubmit.mockResolvedValue({ success: true });Spy on a method without fully mocking:
import * as analytics from "@/lib/analytics";
it("tracks page view", () => {
const spy = vi.spyOn(analytics, "trackEvent");
render(<Dashboard />);
expect(spy).toHaveBeenCalledWith("page_view", { page: "dashboard" });
spy.mockRestore();
});// vi.mocked provides full type safety
vi.mocked(fetch).mockResolvedValue(
new Response(JSON.stringify(data)) // TypeScript knows fetch signature
);
// Typing mock implementations
vi.mock("@/lib/db", () => ({
getUser: vi.fn<[number], Promise<User>>(),
}));
// Asserting mock calls with types
const mockFn = vi.fn<[string, number], boolean>();
mockFn("hello", 42);
expect(mockFn).toHaveBeenCalledWith("hello", 42);vi.mock not hoisted correctly -- Vitest hoists vi.mock() to the top of the file, but variables defined before it are not available inside the factory. Fix: Use vi.hoisted() to declare variables that need to be available inside vi.mock():
const \{ mockPush \} = vi.hoisted(() => (\{
mockPush: vi.fn(),
\}));
vi.mock("next/navigation", () => (\{
useRouter: () => (\{ push: mockPush \}),
\}));Mocking too much -- Mocking every dependency makes tests pass even when integrations are broken. Fix: Use MSW for API mocking (tests real fetch code) and only mock what is truly external.
Mock state leaking between tests -- Mock return values persist across tests in the same file. Fix: Call vi.clearAllMocks() or vi.restoreAllMocks() in beforeEach.
vi.mocked on non-mocked functions -- vi.mocked(fetch) fails at runtime if fetch has not been replaced with vi.fn(). Fix: Always vi.stubGlobal("fetch", vi.fn()) before using vi.mocked(fetch).
MSW intercepting all requests -- onUnhandledRequest: "error" throws for unmatched requests. Fix: Provide handlers for all requests your tests make, or use "warn" during development.
| Alternative | Use When | Don't Use When |
|---|---|---|
| MSW | You want network-level API mocking that works in tests and Storybook | You need to mock non-HTTP modules |
vi.mock / jest.mock | You need to replace module internals (hooks, utility functions) | You only need to mock HTTP calls (prefer MSW) |
vi.spyOn / jest.spyOn | You want to observe calls without changing behavior | You need to completely replace a module |
| Dependency injection | Architecture supports passing dependencies as props or config | You would need to refactor heavily |
vi.mock() replaces an entire module at the top of the file.vi.fn() creates a standalone mock function that tracks calls.vi.spyOn() wraps an existing method to observe calls without fully replacing the module.const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush, back: vi.fn() }),
usePathname: () => "/dashboard",
useSearchParams: () => new URLSearchParams(),
}));vi.mock() is hoisted above imports, so variables declared before it are not available inside the factory. Use vi.hoisted():
const { mockPush } = vi.hoisted(() => ({
mockPush: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));vi.stubGlobal("fetch") for quick one-off mocking.Call vi.clearAllMocks() or vi.restoreAllMocks() in beforeEach. This resets call history and return values between tests.
vi.mock("next/image", () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img {...props} />
),
}));vi.mocked() is a type helper only. If fetch has not been replaced with vi.fn() via vi.stubGlobal("fetch", vi.fn()), calling mock methods on it will fail.
const mockFn = vi.fn<[string, number], boolean>();
vi.mock("@/lib/db", () => ({
getUser: vi.fn<[number], Promise<User>>(),
}));src/mocks/handlers.ts using http.get(), http.post(), etc.src/mocks/server.ts with setupServer(...handlers).vitest.setup.ts, call server.listen() in beforeAll, server.resetHandlers() in afterEach, and server.close() in afterAll.It throws an error for any fetch request that does not match a defined handler. This ensures all API calls in your tests are explicitly handled. Use "warn" during development if this is too strict.
const spy = vi.spyOn(analytics, "trackEvent");
render(<Dashboard />);
expect(spy).toHaveBeenCalledWith("page_view", { page: "dashboard" });
spy.mockRestore();jest.mock() works the same way. Replace vi.fn() with jest.fn() and vi.mocked() with jest.mocked(). The patterns are identical.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥