React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

mockingvi.mockjest.mockmswfetchnext-navigationnext-image

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 fetch with vi.stubGlobal
  • Extracting mockPush for 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 transform
  • vi.fn() creates a mock function that tracks calls, arguments, and return values
  • vi.mocked() is a type helper that casts a function to its mocked type, enabling auto-completion for mock methods
  • vi.stubGlobal() replaces a global like fetch and restores it when you call vi.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.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.

Alternatives

AlternativeUse WhenDon't Use When
MSWYou want network-level API mocking that works in tests and StorybookYou need to mock non-HTTP modules
vi.mock / jest.mockYou need to replace module internals (hooks, utility functions)You only need to mock HTTP calls (prefer MSW)
vi.spyOn / jest.spyOnYou want to observe calls without changing behaviorYou need to completely replace a module
Dependency injectionArchitecture supports passing dependencies as props or configYou 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.ts using http.get(), http.post(), etc.
  • Create a server in src/mocks/server.ts with setupServer(...handlers).
  • In vitest.setup.ts, call server.listen() in beforeAll, server.resetHandlers() in afterEach, and server.close() in afterAll.
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.