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.
Alex Miller
Senior Frontend Engineer specializing in test-driven development and modern JavaScript frameworks.
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 | vi.mock | vi.spyOn |
---|---|---|
Target | Entire modules (files) | Specific methods on an object |
Purpose | Isolate code from external dependencies | Observe or stub internal method calls |
Original Implementation | Never executed; completely replaced | Executed by default, but can be overridden |
Common Use Case | Mocking 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:
- What am I trying to control?
Answer: A whole file/library (like `axios`). → Usevi.mock
.
Answer: A single function on an object (like `logger.log`). → Usevi.spyOn
. - Why am I trying to control it?
Answer: To prevent its original code from running (e.g., it makes a network request). → Usevi.mock
.
Answer: To see if it gets called by my code under test. → Usevi.spyOn
. - Is the dependency external or internal?
Answer: External (from `node_modules` or a different system boundary). → Default tovi.mock
.
Answer: Internal (another class/object within my own application). → Default tovi.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?