JavaScript Testing

vi.mock vs vi.spyOn: 3 Key Reasons You'll Switch in 2025

Struggling with Vitest tests? This guide clarifies vi.mock vs. vi.spyOn, detailing 3 key differences to help you choose the right tool for effective mocking.

A

Alex Miller

Senior Frontend Engineer specializing in test-driven development and modern JavaScript frameworks.

7 min read3 views

Introduction to Mocking in Vitest

Welcome to the world of modern JavaScript testing with Vitest! If you're building robust, reliable applications, you know that unit testing is non-negotiable. A core part of effective unit testing is the ability to isolate the code you're testing from its dependencies. This is where test doubles—like mocks and spies—come into play.

In the Vitest ecosystem, two of the most powerful and commonly used functions for this purpose are vi.mock and vi.spyOn. However, they are often a source of confusion for developers new to the framework, and even for some seasoned pros. Using the wrong one can lead to brittle, confusing, and ineffective tests. Are you replacing an entire library when you just need to watch a single function? Are you trying to spy on a module that hasn't been loaded? This guide is here to clear the fog.

We'll break down the 3 key reasons you might be using the wrong tool and provide a clear framework for choosing between vi.mock and vi.spyOn, ensuring your tests are as clean and effective as your code.

What is vi.mock? The Sledgehammer for Dependencies

Think of vi.mock as the sledgehammer in your testing toolkit. Its purpose is broad and powerful: to completely replace an entire module with a mocked version. When you use vi.mock, you're telling Vitest, "Hey, whenever any part of my code tries to import from this path, give them this fake version instead."

How vi.mock Works

At its core, vi.mock intercepts the module resolution system. Before your test code even runs, Vitest swaps out the real module in its cache with an auto-mocked version. All exports from the original module are replaced with mock functions (like vi.fn()) that do nothing but track calls. This is crucial for isolating your unit under test from external factors like network requests, file system operations, or complex third-party libraries.

A Practical vi.mock Example

Imagine we have a userService that fetches user data using an apiClient. We don't want our unit test to make a real network request.

File: `src/apiClient.js`

// This makes a real network request
export const fetchUser = async (userId) => {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  if (!response.ok) {
    throw new Error('Network response was not ok');
  }
  return response.json();
};

File: `src/userService.js`

import { fetchUser } from './apiClient';

export const getUserFullName = async (userId) => {
  try {
    const user = await fetchUser(userId);
    return `${user.firstName} ${user.lastName}`;
  } catch (error) {
    return 'User not found';
  }
};

Now, let's test getUserFullName without calling the real API. We use vi.mock to replace the entire ./apiClient.js module.

File: `src/userService.test.js`

import { describe, it, expect, vi } from 'vitest';
import { getUserFullName } from './userService';
import { fetchUser } from './apiClient';

// 1. Mock the entire module. This is hoisted to the top.
vi.mock('./apiClient');

describe('userService', () => {
  it('should return the full name for a valid user', async () => {
    // 2. Provide a mock implementation for the exported function
    vi.mocked(fetchUser).mockResolvedValue({ 
      firstName: 'John', 
      lastName: 'Doe' 
    });

    const fullName = await getUserFullName(1);

    // 3. Assert our service logic works correctly
    expect(fullName).toBe('John Doe');
    // 4. Assert that the mocked dependency was called
    expect(fetchUser).toHaveBeenCalledWith(1);
  });
});

Here, vi.mock('./apiClient') ensures that the `import { fetchUser }` inside `userService.js` receives a mock function, not the real one. We have successfully isolated our service from the network.

What is vi.spyOn? The Scalpel for Methods

If vi.mock is the sledgehammer, vi.spyOn is the scalpel. It’s a precision tool designed for a more delicate task: observing or modifying a single method on an existing object, often without affecting the rest of the object or module.

How vi.spyOn Works

vi.spyOn(object, 'methodName') creates a wrapper around the specified method. This wrapper, or "spy," keeps a record of all calls made to the original method: how many times it was called, and with which arguments. Crucially, by default, the spy still calls the original implementation. This allows you to verify interactions without breaking the original functionality. You can also use the spy to stub the method's return value or provide a completely different implementation for a specific test.

A Practical vi.spyOn Example

Let's consider a notificationService that logs events to the console. We want to test that when a notification is sent, the logging method is also called.

File: `src/logger.js`

// A simple logger object
export const logger = {
  log: (message) => {
    console.log(`[LOG]: ${message}`);
  },
  error: (message) => {
    console.error(`[ERROR]: ${message}`);
  }
};

File: `src/notificationService.js`

import { logger } from './logger';

export const sendNotification = (message) => {
  // Business logic for sending a notification...
  console.log(`Sending notification: ${message}`);
  
  // We also want to log that it happened
  logger.log(`Notification sent: ${message}`);
  
  return true;
};

We want to test sendNotification and verify it calls logger.log, but we don't want to pollute our test output with actual `console.log` messages.

File: `src/notificationService.test.js`

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { sendNotification } from './notificationService';
import { logger } from './logger';

describe('notificationService', () => {
  // 1. Create a spy before each test
  const logSpy = vi.spyOn(logger, 'log').mockImplementation(() => {});
  
  // Restore the original method after each test to avoid test pollution
  afterEach(() => {
    logSpy.mockRestore();
  });

  it('should log a message when sending a notification', () => {
    const message = 'Your order has shipped!';
    sendNotification(message);

    // 2. Assert that our spy was called correctly
    expect(logSpy).toHaveBeenCalledTimes(1);
    expect(logSpy).toHaveBeenCalledWith(`Notification sent: ${message}`);
  });
});

Notice we didn't mock the whole logger module. We imported the real logger object and used vi.spyOn to precisely target the `log` method. This is perfect for testing interactions between different parts of your own application code.

The 3 Key Differences: Mock vs. Spy

Understanding the fundamental differences in scope, implementation, and use case will empower you to write better tests.

Difference 1: Scope (Module vs. Method)

This is the most critical distinction. vi.mock operates at the module level. It doesn't care about objects or methods; it cares about file paths. It replaces an entire `import`. vi.spyOn operates at the object property level. It targets a specific method on an already-existing object instance. You can't spy on something that doesn't exist yet.

  • Use vi.mock when: You need to prevent an entire third-party module (e.g., `axios`, `fs`, `lodash`) from being loaded.
  • Use vi.spyOn when: You need to check if a method on your own internal service or object was called by another part of your code.

Difference 2: Implementation (Replacement vs. Observation)

vi.mock is about wholesale replacement. The original code in the mocked module is never, ever executed. Vitest creates a blank slate of mock functions for you to define. vi.spyOn is primarily about observation. By default, it wraps the original method and still executes it. While you can replace the implementation (e.g., with .mockReturnValue()), its core design is to act as a wrapper. This is why spies have a mockRestore() method to remove the wrapper and restore the original function, a concept that doesn't apply to `vi.mock`.

  • Use vi.mock when: The original implementation is problematic for a test environment (e.g., makes network calls, accesses hardware).
  • Use vi.spyOn when: The original implementation is fine, and you just want to assert that it was called (behavioral testing).

Difference 3: Primary Use Case (Isolation vs. Interaction)

These differences in scope and implementation lead to distinct primary use cases. vi.mock is the tool for isolating your system under test from its external dependencies. It helps you draw a hard boundary around your unit of code. vi.spyOn is the tool for verifying interactions between collaborating objects within the same system. It helps you test the contract between different parts of your own application.

  • Use vi.mock for: External dependencies. Think of anything in your `node_modules` folder or any module that communicates with the outside world.
  • Use vi.spyOn for: Internal dependencies. Think of a `UserService` calling a `LoggerService` that you also wrote.

vi.mock vs. vi.spyOn: A Quick Comparison Table

Feature Comparison: vi.mock vs. vi.spyOn
Featurevi.mockvi.spyOn
TargetEntire modules (files)Specific methods on an object
PurposeIsolate code from external dependenciesObserve or stub internal method calls
Original ImplementationNever executed; completely replacedExecuted by default, but can be overridden
Common Use CaseMocking third-party libraries (e.g., `axios`) or environment-specific modules (e.g., `fs`)Verifying that one of your services correctly calls a method on another one of your services

Which One Should You Use? A Decision Guide

Still unsure? Ask yourself these questions when writing a test:

  1. What am I trying to control?
    Answer: A whole file/library (like `axios`). → Use vi.mock.
    Answer: A single function on an object (like `logger.log`). → Use vi.spyOn.
  2. Why am I trying to control it?
    Answer: To prevent its original code from running (e.g., it makes a network request). → Use vi.mock.
    Answer: To see if it gets called by my code under test. → Use vi.spyOn.
  3. Is the dependency external or internal?
    Answer: External (from `node_modules` or a different system boundary). → Default to vi.mock.
    Answer: Internal (another class/object within my own application). → Default to vi.spyOn.

Conclusion: Choosing the Right Tool for the Job

Both vi.mock and vi.spyOn are essential tools in the Vitest arsenal, but they are not interchangeable. vi.mock is your broad-stroke tool for architectural isolation, creating a clean boundary between your code and the outside world. vi.spyOn is your precision instrument for verifying the intricate interactions and collaborations between the objects that make up your application.

By understanding their core differences in scope, implementation, and purpose, you can write tests that are more intentional, readable, and maintainable. You'll spend less time fighting your testing framework and more time building confidence in your code. So next time you reach for a test double, pause and ask: do I need a sledgehammer or a scalpel?