Stop vi.mock Footguns! Your #1 vi.spyOn Secret for 2025
Tired of `vi.mock` footguns in Vitest? Learn the #1 secret for 2025: use `vi.spyOn` for cleaner, more precise, and maintainable tests. Stop the pain!
Alex Miller
Senior Frontend Engineer specializing in test-driven development and modern JavaScript frameworks.
The Frustration is Real
You love Vitest. It's fast, the API is intuitive, and it makes testing feel like less of a chore. But then you hit the wall. A test passes in isolation but fails when you run the whole suite. A simple change breaks a dozen unrelated tests. You're debugging mock implementations, and you realize you're spending more time fighting your tools than testing your code. The culprit? More often than not, it's a misunderstanding of vi.mock
.
For years, developers have reached for vi.mock
as the default tool for isolating dependencies. But its behavior, particularly its hoisting mechanism, creates subtle and frustrating bugs—what we call "footguns." They're features that are so easy to misuse, you end up shooting yourself in the foot.
But what if there was a better, safer, and more precise way for most of your mocking needs? There is. In this guide, we'll expose the common vi.mock
footguns and reveal the #1 secret for writing cleaner, more robust tests in 2025: embracing vi.spyOn
.
Understanding the vi.mock Footgun
To defeat the enemy, you must first understand it. vi.mock
's primary function is to replace an entire module with a mock object. Every export from that module is replaced with a Jest-style mock function (e.g., vi.fn()
). While powerful, this sledgehammer approach comes with significant side effects.
The Hoisting Problem: An Invisible Magic Trick
The most confusing aspect of vi.mock
is that Vitest automatically hoists these calls to the top of your test file. This means that no matter where you write vi.mock('./modules/api.js')
, it's executed before any of your import
statements.
Why is this a footgun? It breaks the natural, top-to-bottom reading flow of your code. It can lead to baffling race conditions where you try to use a variable to configure a mock, only to find the mock has already been created. This non-linear execution is a primary source of "it works on my machine"-style bugs and flaky test suites.
The "All-or-Nothing" Trap
When you use vi.mock
, you're not just mocking one function; you're throwing out the entire module and all its exports. Imagine a utility module with ten functions, and you only need to stub one for a specific test. With vi.mock
, you've inadvertently replaced the other nine with empty mock functions.
To fix this, you often have to use a factory function to manually recreate the parts of the original module you still need:
// This gets complicated quickly!
vi.mock('./utils.js', async (importOriginal) => {
const originalModule = await importOriginal();
return {
...originalModule,
// Only override the one function we care about
functionToMock: vi.fn(),
};
});
This is verbose, brittle, and adds unnecessary complexity. Your test is now tightly coupled to the implementation details of the entire module, not just the part you're testing.
Leaky State Across Tests
Because vi.mock
operates at the module level, mocks can easily leak between tests if not managed perfectly. If you forget to call vi.restoreAllMocks()
or vi.clearAllMocks()
in an afterEach
hook, one test's mock configuration can bleed into the next, causing unpredictable failures that are a nightmare to debug.
Enter vi.spyOn: The Precision Tool for Modern Tests
If vi.mock
is a sledgehammer, vi.spyOn
is a surgical scalpel. It allows you to target a specific method on an existing object without affecting anything else in the module.
How It Works: Surgical Strikes
A "spy" wraps an existing function, allowing you to observe its calls, arguments, and return values, all while letting the original implementation run. More importantly, you can also chain methods like .mockImplementation()
or .mockReturnValue()
onto the spy to completely replace its behavior for the duration of a test.
The key difference is scope: vi.spyOn
targets an object and a method name, not a module path. This makes your intent crystal clear and avoids the pitfalls of hoisting and over-mocking.
The #1 Secret: Partial Mocking with Ease
Let's revisit our scenario of mocking just one function from a service module. Here’s how you’d do it the right way with vi.spyOn
.
Consider this service module:
// src/services/userService.js
export const userService = {
async getUser(id) {
console.log('Fetching user from real API...');
const response = await fetch(`/api/users/${id}`);
return response.json();
},
async getActiveUsers() {
// Some other logic we don't want to mock
const allUsers = await this.getAllUsers();
return allUsers.filter(u => u.active);
},
};
Now, let's test a component that uses getUser
but not getActiveUsers
. With vi.spyOn
, it's trivial and clean:
// src/components/UserProfile.test.js
import { userService } from '../services/userService';
import { renderUserProfile } from './UserProfile';
// IMPORTANT: We use a standard import, no vi.mock needed!
describe('UserProfile', () => {
// Best practice: restore spies after each test
afterEach(() => {
vi.restoreAllMocks();
});
it('should display the user name after fetching', async () => {
// Arrange: Create a spy on the specific method
const getUserSpy = vi.spyOn(userService, 'getUser')
.mockResolvedValue({ id: 1, name: 'Alice' });
// Act
renderUserProfile(1);
// Assert
// Check that our spy was called correctly
expect(getUserSpy).toHaveBeenCalledWith(1);
// Check the component's output (implementation not shown)
// expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
Look how clean that is! There's no hoisting, no factory functions, and no ambiguity. We targeted `userService.getUser` directly, and the `getActiveUsers` function remains completely untouched. The test is readable, precise, and robust.
vi.mock vs. vi.spyOn: A Head-to-Head Comparison
Feature | vi.mock | vi.spyOn | Recommendation |
---|---|---|---|
Scope | Entire Module | Specific method on an object | Use vi.spyOn for targeted changes. |
Hoisting | Yes (automatic) | No | vi.spyOn follows natural code flow, reducing confusion. |
Original Implementation | Replaced entirely | Preserved by default (can be overridden) | vi.spyOn is better for preserving other module functions. |
Use Case | Mocking 3rd-party libraries, default exports, or entire systems. | Partial mocking, observing calls, testing object methods. | Default to vi.spyOn . Reach for vi.mock when necessary. |
Cleanup | vi.restoreAllMocks() | spy.mockRestore() or vi.restoreAllMocks() | Both require cleanup, but spies are easier to manage locally. |
When to Still Use vi.mock
vi.spyOn
is not a silver bullet that replaces vi.mock
entirely. vi.mock
is still the right tool for specific, broad-stroke jobs.
Mocking Entire Third-Party Libraries
When you need to stub out a library like axios
or a component library, you don't control its internal structure. Here, mocking the entire module is often the simplest and most effective approach. vi.mock('axios');
is the correct way to prevent real network requests across your entire test file.
Handling Tricky Default Exports
Spying on default exports can sometimes be tricky depending on how they are defined and exported. While possible, `vi.mock` can sometimes provide a more straightforward API for mocking a default export, especially if it's a function.
Top-Level Module Execution
If a module you're importing executes code at the top level (outside of any exported function), that code will run the moment it's imported. If that code relies on a dependency you need to mock, you must use vi.mock
to ensure the mock is in place before the import happens, thanks to hoisting.
The 2025 Best Practice: A `spyOn`-First Mentality
To stop the footguns and write more resilient tests, adopt this simple philosophy:
Always try vi.spyOn
first.
When you need to mock something, ask yourself: "Am I trying to change the behavior of a specific method on an object I can import?" If the answer is yes, vi.spyOn
is your tool. It will lead to tests that are:
- More Readable: Your mock setup is located right where you use it, not magically hoisted to the top.
- More Precise: You only touch the exact piece of code you need to, reducing unintended side effects.
- More Maintainable: When the original module changes (e.g., a new function is added), your tests won't break unless the specific method you're spying on is removed or renamed.
Only when vi.spyOn
is not a good fit—for the reasons listed above—should you reach for the sledgehammer of vi.mock
.
Conclusion: Test with Confidence
The constant, low-grade anxiety that comes from a flaky test suite can drain your productivity and confidence. By understanding the fundamental difference between vi.mock
and vi.spyOn
, you can eliminate a massive source of that frustration. Moving away from a vi.mock
-by-default habit and toward a `spyOn`-first mentality is the single biggest step you can take to improve your Vitest testing skills in 2025.
Embrace precision. Embrace clarity. Stop the footguns and start writing tests that empower you, not fight you.