Mocking Functions, Modules, APIs, and Browser Behavior Without Over-Mocking
Use Vitest's function and module mocks, mock the network with MSW, stub browser APIs that jsdom doesn't implement, and recognize when a mock is hiding a real bug instead of isolating one.
Mocking exists to make a unit of code testable in isolation by replacing something it depends on — a function, a module, a network call, or a browser API — with a controllable stand-in. Done well, mocking makes tests fast, deterministic, and focused on the code under test. Done poorly, it makes tests pass while the real integration is broken, because the mock diverges from what the real dependency actually does. This lesson works through the four layers of mocking you'll use in a React codebase — vi.fn() for individual functions, vi.mock() for whole modules, request-level API mocking with MSW (Mock Service Worker), and targeted stubs for browser APIs jsdom doesn't provide — and closes with the discipline of knowing when *not* to mock.
Function mocks: vi.fn() and spying on real implementations
vi.fn() creates a bare mock function you can pass as a prop or callback and later assert against — how many times it was called, with what arguments, and in what order relative to other mocks. It's the right tool whenever the thing you're isolating is a single callback: an onSubmit handler, an analytics tracker, a passed-in fetch function.
vi.spyOn(object, 'method') wraps an *existing* method on a real object so you can observe calls to it while still letting the original implementation run (unless you also call .mockImplementation()), or fully replace it. Use spies when you need to intercept a method your code under test calls internally — for example console.error, window.localStorage.setItem, or a method on a class instance — without having to inject that dependency through props.
Module mocks: vi.mock() and its hoisting behavior
vi.mock('./api') replaces an entire module's exports for every import of that module path within the test file, including imports inside the component under test. Vitest hoists vi.mock() calls to the top of the file (above imports) at compile time, which is why you often see it written before the component import — the hoisting is what makes the mock apply even though ./api is imported earlier in the file.
Because of hoisting, you cannot reference an outer-scope variable inside the vi.mock() factory unless it's prefixed with mock (Vitest enforces this to catch a common bug) or you use vi.hoisted() to declare it before the mock. This trips up almost everyone the first time; the fix is either accessing the mocked module via vi.mocked(actualImport) after the fact, or declaring shared mock state with vi.hoisted().
API mocks: intercepting the network with MSW
Mocking fetch directly with vi.fn() works for simple cases but becomes unwieldy once a component makes several requests, uses a shared HTTP client, or you want the *same* mocked backend behavior across unit tests, integration tests, and Storybook. Mock Service Worker (MSW) solves this by intercepting requests at the network level — in Node via an internal interceptor, in the browser via an actual Service Worker — so your component code calls the real fetch/axios/XMLHttpRequest and never knows it's being mocked.
MSW handlers are defined declaratively per HTTP method and URL, live in a server (Node) or worker (browser) instance, and are typically started once for the whole test run with per-test overrides via server.use() for error/edge cases. This keeps mocking centered on 'what does the backend return for this request' rather than 'what does this specific function call return', which is closer to how the app actually behaves in production.
Browser API mocks and the danger of over-mocking
jsdom (the DOM implementation Vitest/Jest use by default) does not implement everything a real browser does — IntersectionObserver, ResizeObserver, matchMedia, scrollIntoView, and the Clipboard API are common gaps. When your component uses one of these, you stub it globally (usually in a test-setup file) so components don't crash on undefined is not a function, while keeping the stub's *behavior* close enough to real to still exercise your component's logic (e.g. actually invoking the IntersectionObserver callback rather than a no-op).
Over-mocking is the failure mode where you mock so much of the system under test that the test no longer proves anything real works — you're testing your mocks, not your code. Common symptoms: mocking the component you're testing's own child components so heavily that no real rendering happens, mocking a module's internal helper functions instead of testing through its public API, or mocking the network response so precisely that it can't catch a real contract mismatch between frontend and backend. The fix is to mock at the boundary of your system (the network, the browser API, time) and let everything inside that boundary — your components, your hooks, your business logic — run for real.
• Mock the network (MSW), not your data-fetching hook — let the hook's real logic run against a fake server. • Mock browser globals jsdom lacks, not your own DOM-reading code that depends on them. • Prefer vi.mock() at a module's public boundary (an API client, a third-party SDK) over mocking internal helper functions within the same module you're testing. • If a test only passes because a mock returns exactly what the assertion expects, with no independent modeling of real behavior, treat that as a smell, not a win.
The mocking toolkit at a glance
vi.mock() calls are hoisted above imports automatically by Vitest's compiler transform. Reset mocks between tests with vi.clearAllMocks() (clears call history) or vi.restoreAllMocks() (restores spies to original implementations) in an afterEach. vi.mocked(fn) gives you TypeScript-typed access to a mocked import's mock methods (.mockResolvedValue, etc.) without unsafe casts.
import { vi } from "vitest";
// 1. Function mock
const onSave = vi.fn().mockReturnValue(true);
// 2. Spy on a real method, optionally replacing it
const spy = vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {});
// 3. Module mock (hoisted — must use vi.hoisted() to share setup state)
const { mockGetUser } = vi.hoisted(() => ({ mockGetUser: vi.fn() }));
vi.mock("./api/userClient", () => ({ getUser: mockGetUser }));
// 4. Browser API stub (usually in test setup)
global.IntersectionObserver = vi.fn().mockImplementation((cb) => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
trigger: (entries: unknown) => cb(entries),
})) as unknown as typeof IntersectionObserver;
// 5. Network mock (MSW) — see full example belowCode Examples
vi.fn() is the simplest mock: a function that records every call. Asserting both call count and arguments catches two different bug classes — a handler that fires twice (e.g. from a duplicated event listener) and a handler that fires with the wrong payload.
vi.mock('./userClient') replaces the whole module for this test file, so UserCard's import of getUser resolves to the mock instead of hitting the network. vi.hoisted() is required here because the mock factory references mockGetUser, and Vitest's hoisting would otherwise move vi.mock() above the const declaration, causing a temporal-dead-zone error.
const { mockGetUser } = vi.hoisted(() => ({ mockGetUser: vi.fn() }));
Common mistake: Declaring const mockGetUser = vi.fn() normally (without vi.hoisted) and referencing it inside vi.mock()'s factory throws 'Cannot access mockGetUser before initialization', because vi.mock() is hoisted above the const but the factory still closes over it.
Unlike mocking the userClient module, MSW mocks at the HTTP boundary: getUser's real implementation runs, calls the real fetch, and MSW intercepts that request before it leaves the process. This means the same handlers can back unit tests, integration tests that exercise multiple components sharing one client, and even a Storybook story, and a genuine contract mismatch (wrong URL, wrong method) shows up as a failed/unhandled request instead of silently passing. onUnhandledRequest: 'error' is deliberate: it fails any test whose component makes a request no handler covers, surfacing typos in URLs immediately instead of quietly returning undefined.
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
The stub replaces only what jsdom is missing (IntersectionObserver itself) and captures the real callback the component registered, so the test can trigger it exactly like a real browser would. LazyImage's own effect logic, ref wiring, and conditional render all run unmocked — the test proves the actual component behavior, not a hollowed-out stand-in for it.
observerCallback([{ isIntersecting: true } as IntersectionObserverEntry]);
Common mistake: Mocking the entire LazyImage component (e.g. vi.mock('./LazyImage', () => ({ LazyImage: () => <img /> }))) in a test that's supposed to verify LazyImage's lazy-loading behavior would make the test pass unconditionally, proving nothing about the real intersection logic — a textbook case of over-mocking.
// SaveButton.tsx
export function SaveButton({ onSave }: { onSave: (value: string) => void }) {
return <button onClick={() => onSave("draft-1")}>Save</button>;
}
// SaveButton.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, it, expect } from "vitest";
import { SaveButton } from "./SaveButton";
it("calls onSave with the current draft id exactly once", async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(<SaveButton onSave={onSave} />);
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith("draft-1");
});// userClient.ts
export async function getUser(id: string) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("Failed to load user");
return res.json();
}
// UserCard.tsx
import { useEffect, useState } from "react";
import { getUser } from "./userClient";
export function UserCard({ id }: { id: string }) {
const [name, setName] = useState<string | null>(null);
useEffect(() => { getUser(id).then((u) => setName(u.name)); }, [id]);
return <p>{name ?? "Loading…"}</p>;
}
// UserCard.test.tsx
import { render, screen } from "@testing-library/react";
import { vi, it, expect } from "vitest";
import { UserCard } from "./UserCard";
// vi.hoisted() defines the mock function BEFORE vi.mock() is hoisted above imports,
// so referencing it inside the factory doesn't hit the "cannot access before initialization" error.
const { mockGetUser } = vi.hoisted(() => ({ mockGetUser: vi.fn() }));
vi.mock("./userClient", () => ({
getUser: mockGetUser,
}));
it("renders the user's name once the module-mocked client resolves", async () => {
mockGetUser.mockResolvedValue({ id: "1", name: "Katherine Johnson" });
render(<UserCard id="1" />);
expect(await screen.
…// mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({ id: params.id, name: "Ada Lovelace" });
}),
];
// mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// vitest.setup.ts
import { server } from "./mocks/server";
import { beforeAll, afterEach, afterAll } from "vitest";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// UserCard.msw.test.tsx — same component, tested against the fake network instead of a mocked module
import { render, screen } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "../mocks/server";
import { it, expect } from "vitest";
import { UserCard } from "./UserCard";
it("renders data returned by the mocked server", async () => {
render(<UserCard id="1" />);
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
});
it("handles a 500 from the server", async () => {
server.use(
http.get("/api/user
…// LazyImage.tsx
import { useEffect, useRef, useState } from "react";
export function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setVisible(true);
});
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return <div ref={ref}>{visible ? <img src={src} alt={alt} /> : <span>placeholder</span>}</div>;
}
// LazyImage.test.tsx
import { render, screen } from "@testing-library/react";
import { vi, it, expect, beforeEach } from "vitest";
import { LazyImage } from "./LazyImage";
let observerCallback: (entries: Partial<IntersectionObserverEntry>[]) => void;
beforeEach(() => {
// Stub only the browser API jsdom lacks — the component's own logic still runs for real.
global.IntersectionObserver = vi.fn().mockImplementation((cb) => {
observerCallback = cb;
return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() };
}) as unknown as typeof IntersectionObserver;
});
it("swaps the placeholder for th
…Where each kind of mock sits in the request path
Choosing where to mock is a trade-off between isolation and realism — MSW at the network edge maximizes realism for API-driven components.
Component Hook/Client module Network Real server
┌─────────┐ calls ┌─────────────────┐ fetch ┌─────────┐ ┌──────────┐
│ UserCard│ ───────▶ │ getUser(id) │ ─────▶ │ fetch │ ──────▶ │ backend │
└─────────┘ └─────────────────┘ └─────────┘ └──────────┘
▲ ▲ ▲
│ │ │
vi.fn() prop vi.mock('./userClient') MSW intercepts here —
mocks a callback replaces the whole real fetch runs, real
passed INTO the module's exports — getUser() runs, only
component getUser() body never runs the network is faked
Closer to the component = more isolated, less realistic.
Closer to the network = less isolated, closer to production behavior.Important Rules
Use vi.fn() for callback props and standalone functions; use vi.spyOn() when you need to observe or override a method on an object you don't own outright.
Remember vi.mock() is hoisted above imports — share state into its factory only via vi.hoisted(), never a plain outer const.
Reset mock state between tests (vi.clearAllMocks() in afterEach, or Vitest's clearMocks/restoreMocks config) so one test's mock calls don't leak into the next.
Prefer MSW over mocking fetch/axios/a data-fetching hook directly whenever more than one component or more than one test needs consistent backend behavior.
Set MSW's onUnhandledRequest to 'error' (or 'warn' at minimum) so an uncovered request fails loudly instead of hanging or returning undefined.
Stub only the specific browser API a component needs (IntersectionObserver, matchMedia, ResizeObserver) in a shared test-setup file; never mock the component under test itself.
Treat a test that only passes because a mock hands back exactly the value the assertion expects — with no realistic modeling of failure, latency, or shape — as a warning sign, not a green checkmark.
Why the boundary you mock at matters in practice
Teams that mock deep — replacing their own hooks, data clients, or child components — tend to accumulate a green test suite that still ships broken integrations, because the seam between 'what the mock returns' and 'what the real backend returns' silently drifts apart over months of API changes. Teams that mock at the network boundary with MSW catch that drift immediately: a renamed field or changed status code either breaks a shared handler (visible, fixed once) or, if the handler wasn't updated, an integration test against the real staging API catches it. The same MSW handlers doubling as fixtures for Storybook and for local development (via the browser Service Worker) also means the 'mock' and the 'demo data' never diverge into two different sources of truth.
const mockGetUser = vi.fn(); vi.mock("./userClient", () => ({ getUser: mockGetUser }));
const { mockGetUser } = vi.hoisted(() => ({ mockGetUser: vi.fn() })); vi.mock("./userClient", () => ({ getUser: mockGetUser }));
Vitest hoists vi.mock() calls above all imports and const declarations in the file. Referencing a plain const from inside the factory throws a 'Cannot access before initialization' error because the factory runs before that const is assigned. vi.hoisted() runs its callback before the hoisted mocks, fixing the ordering.
vi.mock("./UserCard", () => ({ UserCard: () => <div>mocked</div> })); // ...then assert something about UserCard's real lazy-loading or fetch behavior
// Mock the network (MSW) or the data client, and let the real UserCard component render and run its actual logic.
Mocking the very component under test replaces its real implementation with a stub, so any test asserting on its behavior is now testing the stub, not the component — this passes regardless of whether the real component works.
it("shows an error", async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({}) }); // ...repeated ad hoc in every test file that touches the network });
// centralize network behavior in MSW handlers.ts, and override per test with server.use(...) server.use(http.get("/api/users/:id", () => HttpResponse.json({}, { status: 500 })));
Hand-rolling global.fetch mocks per test file duplicates response-shape logic everywhere, drifts from the real API contract over time, and doesn't cover code paths that use axios, XHR, or a different fetch wrapper. Centralizing handlers keeps one source of truth for what the fake backend returns.
Why does referencing a plain const mockFn = vi.fn() inside a vi.mock() factory throw an initialization error, and what fixes it? A. vi.mock() cannot use closures at all; you must inline the implementation B. Vitest hoists vi.mock() calls above imports and other top-level declarations, so the factory runs before a plain const is initialized; vi.hoisted() runs its setup before the hoisted mocks to fix the ordering C. vi.fn() must always be called inside the factory itself, never outside it D. This only happens in CI, not locally, due to module caching differences
B. Vitest hoists vi.mock() calls above imports and other top-level declarations, so the factory runs before a plain const is initialized; vi.hoisted() runs its setup before the hoisted mocks to fix the ordering — Vitest's compiler hoists vi.mock() calls to the very top of the file for correctness (so the mock applies before any import runs). That means a factory closing over a const declared later in file order hits a temporal dead zone. vi.hoisted() exists specifically to declare shared mock state early enough to be safe.
What is the main advantage of mocking a component's network calls with MSW instead of mocking its data-fetching hook or client module directly? A. MSW is faster to set up for a single one-off test B. MSW mocks the network boundary, so the component's real fetching code still runs, and the same handlers can be reused across unit tests, integration tests, and tools like Storybook C. MSW eliminates the need for any assertions about loading or error states D. MSW automatically generates TypeScript types for API responses
B. MSW mocks the network boundary, so the component's real fetching code still runs, and the same handlers can be reused across unit tests, integration tests, and tools like Storybook — By intercepting at the network layer, MSW leaves the real data-fetching code path intact (increasing realism and catching contract mismatches), and centralizes fake-backend behavior in reusable handlers instead of duplicating mock shapes per test file.
A test mocks the very component under test (e.g. vi.mock('./LazyImage', () => ({ LazyImage: () => <img /> }))) and then asserts something about that component's lazy-loading behavior. What's wrong with this? A. Nothing — mocking the component under test is standard practice for isolation B. It's a syntax error in Vitest C. It's over-mocking: the test now exercises the stub instead of the real component, so it can pass even if the actual lazy-loading logic is completely broken D. It will cause an infinite loop because the mock references itself
C. It's over-mocking: the test now exercises the stub instead of the real component, so it can pass even if the actual lazy-loading logic is completely broken — Mocking the unit under test removes the exact code the test is supposed to verify. This is a canonical over-mocking mistake: the assertions become trivially true regardless of whether the real implementation works, giving false confidence.
Take the UserCard + MSW example and add a test for a slow network: use MSW's delay() helper (or a manual Promise-based handler) to make the /api/users/:id handler wait 200ms before responding, then assert UserCard shows 'Loading…' immediately and the name only after findByText resolves. Then refactor UserCard to show a proper error message on a non-ok response, and add the MSW 500 test from the example to assert that message instead of the current placeholder assertion.
http.get('/api/users/:id', async ({ params }) => { await delay(200); return HttpResponse.json({ id: params.id, name: 'Ada Lovelace' }); }); // UserCard.tsx updated to catch non-ok responses in userClient.ts (already throws) and render: // {error ? <p role="alert">{error}</p> : name ?? 'Loading…'} it('shows an error message on a 500', async () => { server.use(http.get('/api/users/:id', () => HttpResponse.json({}, { status: 500 }))); render(<UserCard id="1" />); expect(await screen.findByRole('alert')).toBeInTheDocument(); });
MSW v2 exports a delay helper from 'msw' you can await inside a handler before returning HttpResponse.json(...); combine it with the existing findByText pattern from the async-UI-testing lesson.