|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/mocking
$cat docs/testing-—-mocking-&-spying.md
updated Recently·14 min read·published

Testing — Mocking & Spying

TestingIntermediate
Introduction

Mocking and spying are essential techniques for isolating the code under test from its dependencies. A mock replaces a real dependency with a controlled substitute, while a spy wraps an existing function to observe its behavior without replacing it. Together, they enable you to test components in isolation, assert on interaction patterns, and simulate edge cases that would be difficult to trigger with real dependencies.

Vitest provides the vi object with a comprehensive mocking API. The patterns in this guide apply equally to Jest's jest object with minor syntax differences noted throughout.

Mock Functions

Mock functions (vi.fn()) replace real functions with spies that record their calls, arguments, return values, and context. They are the foundation of most mocking patterns.

mock-functions.test.ts
TypeScript
1// Basic mock function
2import { vi } from "vitest";
3import { processOrder } from "./order-service";
4
5// Create a mock function
6const mockLogger = vi.fn();
7const mockSendEmail = vi.fn();
8
9// Replace dependencies with mocks
10processOrder(
11 { id: "123", items: ["item1"] },
12 { logger: mockLogger, sendEmail: mockSendEmail }
13);
14
15// Assert on mock calls
16expect(mockLogger).toHaveBeenCalledWith("Processing order 123");
17expect(mockSendEmail).toHaveBeenCalledTimes(1);
18expect(mockSendEmail).toHaveBeenCalledWith(
19 expect.objectContaining({ id: "123" })
20);
21
22// Advanced mock function configuration
23const mockCalculator = vi.fn((a: number, b: number) => a + b);
24
25// One-time return value
26const mockApi = vi.fn();
27mockApi.mockReturnValueOnce({ data: "first-call" });
28mockApi.mockReturnValueOnce({ data: "second-call" });
29mockApi.mockReturnValue({ data: "default" });
30
31console.log(mockApi()); // { data: "first-call" }
32console.log(mockApi()); // { data: "second-call" }
33console.log(mockApi()); // { data: "default" }
34console.log(mockApi()); // { data: "default" }
35
36// Dynamic return values based on input
37const mockDb = vi.fn((query: string) => {
38 if (query.includes("users")) return [{ id: 1, name: "Alice" }];
39 if (query.includes("orders")) return [{ id: 100, total: 50 }];
40 return [];
41});
42
43// Assert on specific call
44expect(mockDb).toHaveBeenNthCalledWith(1, expect.stringContaining("users"));
45expect(mockDb.mock.calls[0][0]).toBe("SELECT * FROM users");

info

Use mockReturnValueOnce to simulate sequences of calls. This is useful for testing retry logic, pagination, or any scenario where a function should return different values on successive calls.
Return Values & Implementations

Mock functions can be configured with fixed return values, sequential return values, or custom implementations. Choose the approach that best matches your testing scenario.

return-values.test.ts
TypeScript
1// Fixed return value — always returns the same thing
2const mockGetUser = vi.fn().mockReturnValue({ id: 1, name: "Alice" });
3
4// Sequential return values — different value each call
5const mockFetchPage = vi.fn()
6 .mockReturnValueOnce({ data: "page1", nextCursor: "abc" })
7 .mockReturnValueOnce({ data: "page2", nextCursor: "def" })
8 .mockReturnValueOnce({ data: "page3", nextCursor: null });
9
10// Promise resolution — for async functions
11const mockFetchUser = vi.fn().mockResolvedValue({ id: 1, name: "Alice" });
12const mockFetchError = vi.fn().mockRejectedValue(new Error("Network error"));
13
14// Sequential promise values
15const mockApi = vi.fn()
16 .mockResolvedValueOnce({ status: 200, data: { id: 1 } })
17 .mockRejectedValueOnce(new Error("Rate limited"))
18 .mockResolvedValueOnce({ status: 200, data: { id: 2 } });
19
20// Custom implementation — full control
21const mockAuth = vi.fn((role: string) => {
22 if (role === "admin") return { permissions: ["read", "write", "delete"] };
23 if (role === "user") return { permissions: ["read"] };
24 throw new Error(`Unknown role: ${role}`);
25});
26
27// Implementation with side effects
28const mockAnalytics = vi.fn().mockImplementation((event: string) => {
29 // Call real logic alongside tracking
30 console.log(`Analytics: ${event}`);
31 return { event, timestamp: Date.now() };
32});
33
34// Chaining mock implementations
35const mockClient = {
36 get: vi.fn().mockResolvedValue({ data: "get-response" }),
37 post: vi.fn().mockResolvedValue({ data: "post-response" }),
38 put: vi.fn().mockResolvedValue({ data: "put-response" }),
39 delete: vi.fn().mockResolvedValue({ data: "delete-response" }),
40};

warning

Avoid using mockImplementation when mockReturnValue or mockResolvedValue suffices. Simple return values are easier to read and less prone to bugs. Reserve mockImplementation for cases where you need conditional logic or side effects.
Spy Functions

Spy functions wrap existing methods to track calls while preserving the original implementation. They are ideal for verifying that real functions are called correctly without replacing them.

spy-functions.test.ts
TypeScript
1// Spying on object methods
2import { vi } from "vitest";
3import { analytics } from "./analytics";
4
5// Spy on analytics.track — preserves original behavior
6const trackSpy = vi.spyOn(analytics, "track");
7
8// Call code that uses analytics
9userService.login("alice@example.com");
10
11// Assert the spy was called correctly
12expect(trackSpy).toHaveBeenCalledWith("user_login", {
13 email: "alice@example.com",
14});
15
16// Restore the original implementation after test
17trackSpy.mockRestore();
18
19// Spy with mock implementation (partial mock)
20const sendEmailSpy = vi
21 .spyOn(emailService, "send")
22 .mockImplementation(async (to, subject) => {
23 console.log(`Mock email to ${to}: ${subject}`);
24 return { success: true, id: "mock-id" };
25 });
26
27// Spy on Date.now for time-sensitive tests
28const dateSpy = vi.spyOn(Date, "now").mockReturnValue(1700000000000);
29
30// Spy on console methods
31const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
32
33// Spy on Math.random for deterministic tests
34const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
35
36// Reset vs restore
37spy.mockReset(); // Clears call history, keeps mock implementation
38spy.mockRestore(); // Restores original implementation (removes mock)
39spy.mockClear(); // Clears call history only

info

Always call mockRestore() in afterEach when spying on global objects or module-level singletons. Failing to restore can cause test leakage where one test's spy affects another test's behavior. Use restoreMocks: true in config to automate this.
Module Mocking

Module mocking replaces an entire module with a mock version. This is essential for mocking external dependencies like API clients, database drivers, and third-party libraries.

module-mocking.test.ts
TypeScript
1// Complete module mock
2import { vi } from "vitest";
3
4// Mock the entire 'axios' module
5vi.mock("axios");
6
7// Now imports of 'axios' return a mock
8import axios from "axios";
9
10// Configure the mock
11(axios as any).get.mockResolvedValue({ data: { users: [] } });
12(axios as any).post.mockResolvedValue({ data: { id: 1 } });
13
14test("fetches users", async () => {
15 const result = await fetchUsers();
16 expect(axios.get).toHaveBeenCalledWith("/api/users");
17 expect(result).toEqual([]);
18});
19
20// Mock with factory function — provide custom implementation
21vi.mock("date-fns", () => ({
22 format: vi.fn(() => "2024-01-15"),
23 formatDistance: vi.fn(() => "2 days ago"),
24 parse: vi.fn(() => new Date(2024, 0, 15)),
25}));
26
27import { format } from "date-fns";
28// format() now returns "2024-01-15" regardless of input
29
30// Mock with resolved module
31vi.mock("@/lib/db", async (importOriginal) => {
32 const actual = await importOriginal();
33 return {
34 ...actual, // Keep real exports
35 connect: vi.fn(), // Override specific functions
36 query: vi.fn().mockResolvedValue({ rows: [] }),
37 };
38});
39
40// Mock with __mocks__ directory
41// __mocks__/@sendgrid/mail.ts
42export const send = vi.fn().mockResolvedValue({ statusCode: 202 });
43export const setApiKey = vi.fn();

warning

vi.mock() calls are hoisted to the top of the file by Vitest's transformer. You cannot use variables declared with const or let inside the factory function until after the hoisting. Use vi.hoisted() to declare variables that need to be available before hoisting.
Partial Module Mocking

Sometimes you want to mock only specific exports of a module while keeping the rest real. This is common when testing a module that imports from another file in your project.

partial-mock.test.ts
TypeScript
1// Partial mock — mock specific exports, keep others
2// src/utils.ts
3export function formatDate(date: Date): string {
4 return date.toISOString().split("T")[0];
5}
6
7export function formatCurrency(amount: number): string {
8 return `$${amount.toFixed(2)}`;
9}
10
11export function generateId(): string {
12 return Math.random().toString(36).substr(2, 9);
13}
14
15// src/order.ts
16import { formatDate, formatCurrency, generateId } from "./utils";
17
18export function createOrder(items: any[]) {
19 return {
20 id: generateId(),
21 date: formatDate(new Date()),
22 total: formatCurrency(items.reduce((sum, i) => sum + i.price, 0)),
23 items,
24 };
25}
26
27// Test partial mock
28// order.test.ts
29import { vi } from "vitest";
30
31// Mock only generateId — keep formatDate and formatCurrency real
32vi.mock("./utils", async (importOriginal) => {
33 const actual = await importOriginal();
34 return {
35 ...actual,
36 generateId: vi.fn(() => "fixed-id-123"),
37 };
38});
39
40import { createOrder } from "./order";
41
42test("creates order with fixed ID", () => {
43 const order = createOrder([{ name: "Widget", price: 29.99 }]);
44
45 // generateId is mocked — returns fixed value
46 expect(order.id).toBe("fixed-id-123");
47
48 // formatDate and formatCurrency are REAL
49 expect(order.date).toBe(new Date().toISOString().split("T")[0]);
50 expect(order.total).toBe("$29.99");
51});

info

Partial module mocking with importOriginal() is a Vitest-specific feature. In Jest, you would need to manually import the real module and spread its exports. The async factory pattern is more reliable and readable.
Manual Mocks

Manual mocks are mock implementations stored in a __mocks__ directory that automatically replace the real module when vi.mock() is called. They are useful for mocking complex third-party modules consistently across tests.

manual-mocks.ts
TypeScript
1// Directory structure for manual mocks:
2// src/
3// __mocks__/
4// @sendgrid/
5// mail.ts ← Mocks the "@sendgrid/mail" package
6// stripe.ts ← Mocks the "stripe" package
7// lib/
8// email.ts
9// payment.ts
10
11// src/__mocks__/stripe.ts
12import { vi } from "vitest";
13
14export const Stripe = vi.fn(() => ({
15 customers: {
16 create: vi.fn().mockResolvedValue({ id: "cus_mock_123" }),
17 retrieve: vi.fn().mockResolvedValue({ id: "cus_mock_123", email: "test@test.com" }),
18 },
19 charges: {
20 create: vi.fn().mockResolvedValue({ id: "ch_mock_456", status: "succeeded" }),
21 list: vi.fn().mockResolvedValue({ data: [] }),
22 },
23 paymentIntents: {
24 create: vi.fn().mockResolvedValue({ id: "pi_mock_789", status: "requires_payment_method" }),
25 confirm: vi.fn().mockResolvedValue({ id: "pi_mock_789", status: "succeeded" }),
26 },
27 webhooks: {
28 constructEvent: vi.fn().mockReturnValue({ type: "payment_intent.succeeded" }),
29 },
30}));
31
32export default Stripe;
33
34// Using the manual mock in a test
35// src/lib/payment.test.ts
36import { vi } from "vitest";
37
38// This automatically uses src/__mocks__/stripe.ts
39vi.mock("stripe");
40
41import { Stripe } from "stripe";
42import { processPayment } from "./payment";
43
44test("processes payment successfully", async () => {
45 const stripe = new Stripe("sk_test_mock");
46 const result = await processPayment(100, "usd");
47
48 expect(stripe.paymentIntents.create).toHaveBeenCalledWith({
49 amount: 100,
50 currency: "usd",
51 });
52 expect(result.status).toBe("succeeded");
53});
MSW (Mock Service Worker) for API Mocking

MSW intercepts network requests at the service worker level, allowing you to mock API responses without modifying any application code. It works identically in tests and development, making it the most realistic mocking approach for HTTP dependencies.

msw-mocking.ts
TypeScript
1// src/test/mocks/handlers.ts — define API handlers
2import { http, HttpResponse } from "msw";
3
4export const handlers = [
5 // GET request handler
6 http.get("/api/users", () => {
7 return HttpResponse.json([
8 { id: 1, name: "Alice", email: "alice@example.com" },
9 { id: 2, name: "Bob", email: "bob@example.com" },
10 ]);
11 }),
12
13 // POST request handler with request body access
14 http.post("/api/orders", async ({ request }) => {
15 const body = await request.json();
16 return HttpResponse.json(
17 { id: 101, ...body, status: "created" },
18 { status: 201 }
19 );
20 }),
21
22 // Dynamic route parameter
23 http.get("/api/users/:id", ({ params }) => {
24 const { id } = params;
25 return HttpResponse.json({
26 id: Number(id),
27 name: `User ${id}`,
28 email: `user${id}@example.com`,
29 });
30 }),
31
32 // Error simulation
33 http.get("/api/error", () => {
34 return HttpResponse.json(
35 { error: "Internal server error" },
36 { status: 500 }
37 );
38 }),
39
40 // Network delay simulation
41 http.get("/api/slow", async () => {
42 await new Promise((resolve) => setTimeout(resolve, 2000));
43 return HttpResponse.json({ data: "slow response" });
44 }),
45];
46
47// src/test/setup.ts — configure MSW for testing
48import { setupServer } from "msw/node";
49import { handlers } from "./mocks/handlers";
50
51export const server = setupServer(...handlers);
52
53// Start server before all tests
54beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
55
56// Reset handlers between tests
57afterEach(() => server.resetHandlers());
58
59// Close server after all tests
60afterAll(() => server.close());
61
62// Test with MSW — no mocking needed in test file!
63test("fetches and displays users", async () => {
64 render(<UserList />);
65 expect(await screen.findByText("Alice")).toBeInTheDocument();
66 expect(await screen.findByText("Bob")).toBeInTheDocument();
67});
68
69// Override handler for a specific test
70test("handles error state", async () => {
71 server.use(
72 http.get("/api/users", () => {
73 return HttpResponse.json({ error: "Unauthorized" }, { status: 401 });
74 })
75 );
76
77 render(<UserList />);
78 expect(await screen.findByText("Unauthorized")).toBeInTheDocument();
79});

info

MSW is the recommended approach for API mocking in 2024+. Unlike vi.mock("axios") which couples your tests to the HTTP client implementation, MSW works at the network level. You can swap axios for fetch, or vice versa, without changing your tests.
Mocking Timers

Fake timers replace setTimeout, setInterval, Date.now, and other time-related functions with controllable versions. This allows you to test time-dependent code without waiting for real time to pass.

timers.test.ts
TypeScript
1// Basic fake timer usage
2import { vi } from "vitest";
3
4beforeEach(() => {
5 vi.useFakeTimers();
6});
7
8afterEach(() => {
9 vi.useRealTimers();
10});
11
12test("debounce delays execution", () => {
13 const callback = vi.fn();
14 const debouncedFn = debounce(callback, 300);
15
16 debouncedFn();
17 debouncedFn();
18 debouncedFn();
19
20 // Callback should not have been called yet
21 expect(callback).not.toHaveBeenCalled();
22
23 // Fast-forward time by 300ms
24 vi.advanceTimersByTime(300);
25
26 // Now the debounced callback should have been called once
27 expect(callback).toHaveBeenCalledTimes(1);
28});
29
30test("polling interval", () => {
31 const pollCallback = vi.fn();
32 setInterval(pollCallback, 1000);
33
34 // Advance by 5 seconds
35 vi.advanceTimersByTime(5000);
36
37 // Should have been called 5 times
38 expect(pollCallback).toHaveBeenCalledTimes(5);
39
40 // Advance all remaining timers
41 vi.runAllTimers();
42});
43
44test("date-sensitive logic", () => {
45 // Set a specific date
46 vi.setSystemTime(new Date(2024, 0, 15, 12, 0, 0));
47
48 const result = generateDailyReport();
49 expect(result.date).toBe("2024-01-15");
50
51 // Change time and test again
52 vi.setSystemTime(new Date(2024, 0, 16, 12, 0, 0));
53 const nextResult = generateDailyReport();
54 expect(nextResult.date).toBe("2024-01-16");
55});
56
57test("timeout with promise", async () => {
58 const promise = delay(1000);
59
60 // Advance time to resolve the promise
61 await vi.advanceTimersByTimeAsync(1000);
62
63 await expect(promise).resolves.toBeUndefined();
64});

warning

Use vi.advanceTimersByTimeAsync() instead of vi.advanceTimersByTime() when dealing with promises. The async version properly handles promise micro-tasks that may be scheduled by timer callbacks. The synchronous version can cause false failures with promise-based code.
Mocking fetch

The native fetch API can be mocked globally or per-test. While MSW is preferred for most scenarios, direct fetch mocking is sometimes simpler for minimal setups.

fetch-mock.test.ts
TypeScript
1// Global fetch mock
2import { vi } from "vitest";
3
4// Mock fetch globally
5const mockFetch = vi.fn();
6vi.stubGlobal("fetch", mockFetch);
7
8// Helper to mock fetch responses
9function mockFetchResponse(data: unknown, status = 200) {
10 mockFetch.mockResolvedValueOnce({
11 ok: status >= 200 && status < 300,
12 status,
13 json: () => Promise.resolve(data),
14 headers: new Headers({ "Content-Type": "application/json" }),
15 });
16}
17
18test("fetches user data", async () => {
19 mockFetchResponse({ id: 1, name: "Alice" });
20
21 const user = await fetchUser(1);
22
23 expect(fetch).toHaveBeenCalledWith("/api/users/1");
24 expect(user.name).toBe("Alice");
25});
26
27test("handles fetch error", async () => {
28 mockFetchResponse({ error: "Not found" }, 404);
29
30 await expect(fetchUser(999)).rejects.toThrow("Not found");
31});
32
33test("network failure", async () => {
34 mockFetch.mockRejectedValueOnce(new Error("Network error"));
35
36 await expect(fetchUser(1)).rejects.toThrow("Network error");
37});
38
39// Cleanup
40afterEach(() => {
41 mockFetch.mockReset();
42});
Best Practices
  • Mock at the right level — Prefer MSW for HTTP mocking, spy-on-methods for internal dependencies, and module mocking for third-party libraries. Each level has different coupling tradeoffs.
  • Avoid over-mocking — If you mock everything, your tests pass but your application breaks. Only mock what is necessary to isolate the code under test.
  • Use clearMocks: true — Automatically reset mock call history between tests to prevent leakage. Set this in your Vitest or Jest config.
  • Favor mockReturnValue over mockImplementation — Simpler mocks are easier to understand and maintain. Use implementations only when you need conditional logic.
  • Restore spies in afterEach — Always clean up spies on shared objects (Date.now, Math.random, console) to avoid affecting other tests.
  • Write mock contracts — For complex mocks, define the mock contract in a shared file that documents what each mock function returns and its behavior.

info

A good heuristic: if your mock setup is more complex than the code you are testing, consider refactoring the code instead. Mocks that are complicated to configure often indicate that the code has too many dependencies or unclear boundaries.
$Blueprint — Engineering Documentation·Section ID: TEST-MOCK·Revision: 1.0