Testing — Mocking & Spying
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 (vi.fn()) replace real functions with spies that record their calls, arguments, return values, and context. They are the foundation of most mocking patterns.
| 1 | // Basic mock function |
| 2 | import { vi } from "vitest"; |
| 3 | import { processOrder } from "./order-service"; |
| 4 | |
| 5 | // Create a mock function |
| 6 | const mockLogger = vi.fn(); |
| 7 | const mockSendEmail = vi.fn(); |
| 8 | |
| 9 | // Replace dependencies with mocks |
| 10 | processOrder( |
| 11 | { id: "123", items: ["item1"] }, |
| 12 | { logger: mockLogger, sendEmail: mockSendEmail } |
| 13 | ); |
| 14 | |
| 15 | // Assert on mock calls |
| 16 | expect(mockLogger).toHaveBeenCalledWith("Processing order 123"); |
| 17 | expect(mockSendEmail).toHaveBeenCalledTimes(1); |
| 18 | expect(mockSendEmail).toHaveBeenCalledWith( |
| 19 | expect.objectContaining({ id: "123" }) |
| 20 | ); |
| 21 | |
| 22 | // Advanced mock function configuration |
| 23 | const mockCalculator = vi.fn((a: number, b: number) => a + b); |
| 24 | |
| 25 | // One-time return value |
| 26 | const mockApi = vi.fn(); |
| 27 | mockApi.mockReturnValueOnce({ data: "first-call" }); |
| 28 | mockApi.mockReturnValueOnce({ data: "second-call" }); |
| 29 | mockApi.mockReturnValue({ data: "default" }); |
| 30 | |
| 31 | console.log(mockApi()); // { data: "first-call" } |
| 32 | console.log(mockApi()); // { data: "second-call" } |
| 33 | console.log(mockApi()); // { data: "default" } |
| 34 | console.log(mockApi()); // { data: "default" } |
| 35 | |
| 36 | // Dynamic return values based on input |
| 37 | const 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 |
| 44 | expect(mockDb).toHaveBeenNthCalledWith(1, expect.stringContaining("users")); |
| 45 | expect(mockDb.mock.calls[0][0]).toBe("SELECT * FROM users"); |
info
Mock functions can be configured with fixed return values, sequential return values, or custom implementations. Choose the approach that best matches your testing scenario.
| 1 | // Fixed return value — always returns the same thing |
| 2 | const mockGetUser = vi.fn().mockReturnValue({ id: 1, name: "Alice" }); |
| 3 | |
| 4 | // Sequential return values — different value each call |
| 5 | const 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 |
| 11 | const mockFetchUser = vi.fn().mockResolvedValue({ id: 1, name: "Alice" }); |
| 12 | const mockFetchError = vi.fn().mockRejectedValue(new Error("Network error")); |
| 13 | |
| 14 | // Sequential promise values |
| 15 | const 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 |
| 21 | const 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 |
| 28 | const 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 |
| 35 | const 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
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.
| 1 | // Spying on object methods |
| 2 | import { vi } from "vitest"; |
| 3 | import { analytics } from "./analytics"; |
| 4 | |
| 5 | // Spy on analytics.track — preserves original behavior |
| 6 | const trackSpy = vi.spyOn(analytics, "track"); |
| 7 | |
| 8 | // Call code that uses analytics |
| 9 | userService.login("alice@example.com"); |
| 10 | |
| 11 | // Assert the spy was called correctly |
| 12 | expect(trackSpy).toHaveBeenCalledWith("user_login", { |
| 13 | email: "alice@example.com", |
| 14 | }); |
| 15 | |
| 16 | // Restore the original implementation after test |
| 17 | trackSpy.mockRestore(); |
| 18 | |
| 19 | // Spy with mock implementation (partial mock) |
| 20 | const 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 |
| 28 | const dateSpy = vi.spyOn(Date, "now").mockReturnValue(1700000000000); |
| 29 | |
| 30 | // Spy on console methods |
| 31 | const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); |
| 32 | |
| 33 | // Spy on Math.random for deterministic tests |
| 34 | const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); |
| 35 | |
| 36 | // Reset vs restore |
| 37 | spy.mockReset(); // Clears call history, keeps mock implementation |
| 38 | spy.mockRestore(); // Restores original implementation (removes mock) |
| 39 | spy.mockClear(); // Clears call history only |
info
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.
| 1 | // Complete module mock |
| 2 | import { vi } from "vitest"; |
| 3 | |
| 4 | // Mock the entire 'axios' module |
| 5 | vi.mock("axios"); |
| 6 | |
| 7 | // Now imports of 'axios' return a mock |
| 8 | import 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 | |
| 14 | test("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 |
| 21 | vi.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 | |
| 27 | import { format } from "date-fns"; |
| 28 | // format() now returns "2024-01-15" regardless of input |
| 29 | |
| 30 | // Mock with resolved module |
| 31 | vi.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 |
| 42 | export const send = vi.fn().mockResolvedValue({ statusCode: 202 }); |
| 43 | export const setApiKey = vi.fn(); |
warning
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.
| 1 | // Partial mock — mock specific exports, keep others |
| 2 | // src/utils.ts |
| 3 | export function formatDate(date: Date): string { |
| 4 | return date.toISOString().split("T")[0]; |
| 5 | } |
| 6 | |
| 7 | export function formatCurrency(amount: number): string { |
| 8 | return `$${amount.toFixed(2)}`; |
| 9 | } |
| 10 | |
| 11 | export function generateId(): string { |
| 12 | return Math.random().toString(36).substr(2, 9); |
| 13 | } |
| 14 | |
| 15 | // src/order.ts |
| 16 | import { formatDate, formatCurrency, generateId } from "./utils"; |
| 17 | |
| 18 | export 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 |
| 29 | import { vi } from "vitest"; |
| 30 | |
| 31 | // Mock only generateId — keep formatDate and formatCurrency real |
| 32 | vi.mock("./utils", async (importOriginal) => { |
| 33 | const actual = await importOriginal(); |
| 34 | return { |
| 35 | ...actual, |
| 36 | generateId: vi.fn(() => "fixed-id-123"), |
| 37 | }; |
| 38 | }); |
| 39 | |
| 40 | import { createOrder } from "./order"; |
| 41 | |
| 42 | test("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
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.
| 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 |
| 12 | import { vi } from "vitest"; |
| 13 | |
| 14 | export 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 | |
| 32 | export default Stripe; |
| 33 | |
| 34 | // Using the manual mock in a test |
| 35 | // src/lib/payment.test.ts |
| 36 | import { vi } from "vitest"; |
| 37 | |
| 38 | // This automatically uses src/__mocks__/stripe.ts |
| 39 | vi.mock("stripe"); |
| 40 | |
| 41 | import { Stripe } from "stripe"; |
| 42 | import { processPayment } from "./payment"; |
| 43 | |
| 44 | test("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 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.
| 1 | // src/test/mocks/handlers.ts — define API handlers |
| 2 | import { http, HttpResponse } from "msw"; |
| 3 | |
| 4 | export 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 |
| 48 | import { setupServer } from "msw/node"; |
| 49 | import { handlers } from "./mocks/handlers"; |
| 50 | |
| 51 | export const server = setupServer(...handlers); |
| 52 | |
| 53 | // Start server before all tests |
| 54 | beforeAll(() => server.listen({ onUnhandledRequest: "error" })); |
| 55 | |
| 56 | // Reset handlers between tests |
| 57 | afterEach(() => server.resetHandlers()); |
| 58 | |
| 59 | // Close server after all tests |
| 60 | afterAll(() => server.close()); |
| 61 | |
| 62 | // Test with MSW — no mocking needed in test file! |
| 63 | test("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 |
| 70 | test("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
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.
| 1 | // Basic fake timer usage |
| 2 | import { vi } from "vitest"; |
| 3 | |
| 4 | beforeEach(() => { |
| 5 | vi.useFakeTimers(); |
| 6 | }); |
| 7 | |
| 8 | afterEach(() => { |
| 9 | vi.useRealTimers(); |
| 10 | }); |
| 11 | |
| 12 | test("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 | |
| 30 | test("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 | |
| 44 | test("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 | |
| 57 | test("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
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.
| 1 | // Global fetch mock |
| 2 | import { vi } from "vitest"; |
| 3 | |
| 4 | // Mock fetch globally |
| 5 | const mockFetch = vi.fn(); |
| 6 | vi.stubGlobal("fetch", mockFetch); |
| 7 | |
| 8 | // Helper to mock fetch responses |
| 9 | function 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 | |
| 18 | test("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 | |
| 27 | test("handles fetch error", async () => { |
| 28 | mockFetchResponse({ error: "Not found" }, 404); |
| 29 | |
| 30 | await expect(fetchUser(999)).rejects.toThrow("Not found"); |
| 31 | }); |
| 32 | |
| 33 | test("network failure", async () => { |
| 34 | mockFetch.mockRejectedValueOnce(new Error("Network error")); |
| 35 | |
| 36 | await expect(fetchUser(1)).rejects.toThrow("Network error"); |
| 37 | }); |
| 38 | |
| 39 | // Cleanup |
| 40 | afterEach(() => { |
| 41 | mockFetch.mockReset(); |
| 42 | }); |
- 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