Mocking in Tests
Mock modules, functions, APIs, and Next.js internals to isolate the code under test.
Recipe
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.
Working Example
// 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:
- Module-level mock for
next/navigation - Mocking
fetchwithvi.stubGlobal - Extracting
mockPushfor assertion - Testing loading, success, error, and navigation states
Deep Dive
How It Works
vi.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 likefetchand restores it when you callvi.restoreAllMocks()- Module mocks persist for the entire test file unless you call
vi.resetModules()between tests
Variations
MSW (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();
});TypeScript Notes
// 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);Gotchas
-
vi.mocknot hoisted correctly -- Vitest hoistsvi.mock()to the top of the file, but variables defined before it are not available inside the factory. Fix: Usevi.hoisted()to declare variables that need to be available insidevi.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()orvi.restoreAllMocks()inbeforeEach. -
vi.mockedon non-mocked functions --vi.mocked(fetch)fails at runtime iffetchhas not been replaced withvi.fn(). Fix: Alwaysvi.stubGlobal("fetch", vi.fn())before usingvi.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.
Alternatives
| 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 |
FAQs
What is the difference between vi.mock(), vi.fn(), and vi.spyOn()?
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.
How do I mock next/navigation in a test?
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush, back: vi.fn() }),
usePathname: () => "/dashboard",
useSearchParams: () => new URLSearchParams(),
}));Gotcha: Why can't I reference variables inside a vi.mock() factory?
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 }),
}));What is MSW and when should I use it over vi.stubGlobal("fetch")?
- MSW (Mock Service Worker) intercepts fetch at the network level.
- Use MSW when you have many API endpoints or want mocks that work in both tests and Storybook.
- Use
vi.stubGlobal("fetch")for quick one-off mocking.
How do I prevent mock state from leaking between tests?
Call vi.clearAllMocks() or vi.restoreAllMocks() in beforeEach. This resets call history and return values between tests.
How do I mock next/image in tests?
vi.mock("next/image", () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img {...props} />
),
}));Gotcha: Why does vi.mocked(fetch) throw at runtime?
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.
How do I type mock function parameters and return values in TypeScript?
const mockFn = vi.fn<[string, number], boolean>();
vi.mock("@/lib/db", () => ({
getUser: vi.fn<[number], Promise<User>>(),
}));How do I set up MSW with Vitest?
- Create handlers in
src/mocks/handlers.tsusinghttp.get(),http.post(), etc. - Create a server in
src/mocks/server.tswithsetupServer(...handlers). - In
vitest.setup.ts, callserver.listen()inbeforeAll,server.resetHandlers()inafterEach, andserver.close()inafterAll.
What does onUnhandledRequest: "error" do in MSW?
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.
How do I spy on a function without changing its behavior?
const spy = vi.spyOn(analytics, "trackEvent");
render(<Dashboard />);
expect(spy).toHaveBeenCalledWith("page_view", { page: "dashboard" });
spy.mockRestore();What is the Jest equivalent of vi.mock()?
jest.mock() works the same way. Replace vi.fn() with jest.fn() and vi.mocked() with jest.mocked(). The patterns are identical.
Related
- Testing Async Behavior -- using MSW with async tests
- Testing Forms -- mocking form submission handlers
- Testing Server Components & Actions -- mocking database and external services
- React Testing Library Fundamentals -- core testing patterns