I Ditched vi.mock: Why vi.spyOn is My Go-To Fix for 2025
Tired of brittle tests? Discover why vi.spyOn is replacing vi.mock as the go-to for modern Vitest testing in 2025. Boost maintainability and realism.
Elena Petrova
Senior Frontend Engineer specializing in test-driven development and modern JavaScript frameworks.
Introduction: The Mocking Dilemma
In the world of JavaScript testing, the debate over mocking is as old as the frameworks themselves. How much should we mock? When does a helpful test double become a brittle chain, tying our tests to implementation details? For years, my go-to tool in the Vitest ecosystem was vi.mock
. It felt powerful, comprehensive, and was often the first solution presented in tutorials. But as projects grew and refactoring became more frequent, I noticed a pattern: my tests were breaking not because of logic errors, but because of their rigid dependency on module structure.
This led me down a path of re-evaluation. After extensive experimentation, I've made a decisive shift in my testing strategy. For 2025 and beyond, I've ditched vi.mock
as my default and embraced the surgical precision of vi.spyOn
. This isn't just a stylistic preference; it's a fundamental change that has led to more resilient, maintainable, and meaningful tests. This post explores why.
The Old Way: Understanding vi.mock
Before we can appreciate the elegance of vi.spyOn
, we must first understand the tool it often replaces. vi.mock
is a powerful feature in Vitest that allows you to replace an entire module with a custom implementation. Its primary purpose is to isolate the code under test from its dependencies.
Imagine we have a simple service that fetches user data:
// utils/api.js
export const fetchUserData = 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();
};
And a function that uses this service:
// services/userService.js
import { fetchUserData } from '../utils/api';
export const getFormattedUserName = async (userId) => {
try {
const user = await fetchUserData(userId);
return `${user.firstName} ${user.lastName}`;
} catch (error) {
return 'User not found';
}
};
To test getFormattedUserName
without making a real network request, we would use vi.mock
to replace the entire api.js
module.
// tests/userService.test.js
import { describe, it, expect, vi } from 'vitest';
import { getFormattedUserName } from '../services/userService';
// Hoisted to the top: vi.mock replaces the entire module
vi.mock('../utils/api', () => ({
fetchUserData: vi.fn(),
}));
describe('getFormattedUserName with vi.mock', () => {
it('should return the formatted name on successful fetch', async () => {
// We need to import the mocked module *after* the mock definition
const { fetchUserData } = await import('../utils/api');
fetchUserData.mockResolvedValue({ firstName: 'John', lastName: 'Doe' });
const result = await getFormattedUserName(1);
expect(result).toBe('John Doe');
expect(fetchUserData).toHaveBeenCalledWith(1);
});
});
The Problem: This works, but it's a heavy-handed approach. We've replaced the entire /utils/api
module. If that module exported ten other functions, they would all be undefined unless we explicitly mocked them too. More importantly, our test is now tightly coupled to the fact that userService.js
imports fetchUserData
from ../utils/api
. If we refactor api.js
and move fetchUserData
elsewhere, this test breaks instantly, even if the application logic is perfectly sound.
The Modern Approach: Embracing vi.spyOn
Enter vi.spyOn
. Instead of replacing an entire module, vi.spyOn
targets a specific method on an object. It wraps the original function, allowing you to observe its calls, arguments, and return values, while optionally providing a mock implementation. The key difference is that the rest of the module and the original object remain intact.
Let's refactor our test using vi.spyOn
. For this to work best, we often import the entire module as an object.
// services/userService.js (no changes needed)
// ...
// utils/api.js (let's export it as a single object for easier spying)
const api = {
fetchUserData: async (userId) => {
// ... same implementation
}
};
export default api;
And the updated test:
// tests/userService.spy.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import api from '../utils/api'; // Import the real module
import { getFormattedUserName } from '../services/userService';
describe('getFormattedUserName with vi.spyOn', () => {
let apiSpy;
beforeEach(() => {
// Create the spy before each test
apiSpy = vi.spyOn(api, 'fetchUserData').mockResolvedValue({
firstName: 'Jane',
lastName: 'Smith'
});
});
afterEach(() => {
// Restore the original implementation after each test
apiSpy.mockRestore();
});
it('should return the formatted name on successful fetch', async () => {
const result = await getFormattedUserName(2);
expect(result).toBe('Jane Smith');
expect(apiSpy).toHaveBeenCalledWith(2);
});
});
The Benefit: This test is far more resilient. We are testing the real integration between our service and the api
module, only intercepting the specific fetchUserData
method at the boundary. If we add new functions to api.js
or refactor its internals, this test remains unaffected as long as the fetchUserData
method exists on the exported api
object. It's a surgical strike versus a carpet bomb.
Head-to-Head Comparison: vi.mock vs. vi.spyOn
The differences become clearer when laid out side-by-side.
Feature | vi.mock | vi.spyOn | Winner / Best For... |
---|---|---|---|
Scope | Entire Module | Specific Method on an Object | vi.spyOn for precision |
Test Brittleness | High. Tightly coupled to module structure and file paths. | Low. Resilient to internal refactoring of the dependency. | vi.spyOn for maintainability |
Setup | Requires hoisting (vi.mock at top of file). Can be confusing with imports. | Standard setup inside beforeEach or the test itself. More intuitive. | vi.spyOn for clarity |
Realism | Low. The entire dependency is fake. | High. The original module is used, with only one method intercepted. | vi.spyOn for integration-style unit tests |
Use Case | Mocking 3rd-party libraries (e.g., fs , axios ) or modules with heavy side-effects. | Observing or stubbing specific functions, testing interactions between your own modules. | Depends on the boundary you're testing |
Cleanup | Automatically handled between test files. | Requires manual cleanup with mockRestore() or vi.restoreAllMocks() . | vi.mock is slightly more convenient here |
Practical Scenarios: When to Choose Which
My philosophy is not to eliminate vi.mock
entirely but to demote it from its default status. The choice depends on the boundary you are testing.
When vi.spyOn Shines (My Default)
This is my go-to for 90% of cases involving my own application code.
- Testing Component Methods: You want to test a component that calls a method from a utility service. Spy on the utility service method to ensure it was called with the correct arguments, without mocking the entire utility service.
- Verifying Inter-Service Communication: You have a
PaymentService
that calls aNotificationService
. You can spy onnotificationService.sendEmail
to confirm an email is sent when a payment succeeds, while allowing the rest of theNotificationService
to remain real. - Partial Mocking: You need to control the return value of one function in a large helper module but want to use the real implementations of other functions in that same module for your test.
When vi.mock is Still Necessary
There are specific, important scenarios where vi.mock
remains the superior or only choice.
- Third-Party Dependencies: When you're dealing with a library installed from npm, like
axios
orjsonwebtoken
, you don't control its structure.vi.mock('axios')
is the clean, standard way to prevent your tests from making real HTTP requests. - Platform-Specific APIs: Mocking built-in Node.js modules like
fs
(File System) or browser-only globals that don't exist in a JSDOM environment is a perfect use case forvi.mock
. - Circular Dependencies: In complex or legacy codebases,
vi.mock
can help break circular dependency chains that would otherwise cause issues during testing. - Top-Level Function Exports: If a module uses
export function myFunction() { ... }
instead of exporting an object, spying becomes more difficult.vi.mock
is often simpler in these cases.
Conclusion: A Spy-First Philosophy for 2025
The shift from a mock
-first to a spyOn
-first mindset is a step towards more mature and robust testing. By favoring vi.spyOn
, we write tests that are less concerned with how a dependency is structured and more focused on the contract of the function being called. This creates a test suite that is not only easier to maintain but also provides a more accurate picture of how your application's components interact.
For 2025, I challenge you to do the same. When you write your next test in Vitest, pause before typing vi.mock
. Ask yourself: do I need to replace the entire world, or can I just watch the door? More often than not, a simple spy is all you need. It's a small change in habit that pays huge dividends in code quality and developer sanity.