`vi.mock` Is a Footgun: Why `vi.spyOn` Should Be Your Default

Which Vitest API is your favorite? There are so many great ones to choose from. test
is a classic, vi.fn
might have the best name, and expect
is a great Swiss Army knife. Like a proud parent, I have trouble choosing my favorite. But I know which API I hate the most: vi.mock
. It creates more confusion than clarity and encourages mocking that’s too broad and brittle for maintainable codebases.
And if you're using Jest, yes, this applies to jest.mock
too. The APIs are identical, and so are the problems.
Let me start with a confession: I am a recovering vi.mock
abuser. I enjoy the mockist style of TDD, so naturally every time I needed to isolate my code from a dependency, I'd reach for that sledgehammer. After all, it says "mock" right on the tin, what's not to love?
But I've seen the light, and I'm here to show you why vi.spyOn
should be your preferred choice. It's a sharper, safer tool that gives you precision without the pitfalls.
Understanding Mock Mechanics: What's Actually Happening Under the Hood
When you see vi.mock('./someModule')
in a test file, it may look like a simple inline method call but under the hood your test runner is breaking the rules. Before a single line of code is executed, vitest pre-parses the test file and hoists all vi.mock(...)
calls to the top. It then evaluates every vi.mock
before any imports or any other code in the file has a chance to run. This behavior applies globally, affecting every test in the file.
This might be surprising to those of you who've studied the ES modules spec, which requires static imports to be evaluated before any code runs. Vitest, like Jest before it, cheats. It makes mocking work by bending the rules. In every other JavaScript runtime, imports happen first, then code runs, just as the spec demands. But with vi.mock
, that flow is flipped: your mocks run before anything else, no matter where they appear. That inversion breaks your mental model, making tests harder to follow and easier to misuse.
Once you understand how the test runner works, this behavior might feel manageable. But that’s the problem, it’s inside baseball. You might know what's going on, but it's risky to assume the next developer will. Tests that rely on this behavior depend on deep, tool-specific knowledge that most people don’t carry around in their heads.
In contrast, vi.spyOn(module, 'method')
runs at runtime, exactly where you put it. It patches a specific method on an already imported module. Thanks to ES modules' live bindings, you get a precise override without affecting the rest of the module. It’s surgical, predictable, and plays by the rules.
Let's compare them side-by-side with a simple example:
// The vi.mock approach
import { vi, test } from 'vitest';
import { getUser } from './userService';
// vi.mock lives in the root scope. But this line is evaluated first before the imports above.
vi.mock('./userService');
test('fetches and displays user data', async () => {
// We have to manually configure the mock after importing
vi.mocked(getUser).mockResolvedValue({ id: 117, name: 'John' });
// Rest of test...
});
// The vi.spyOn approach
import { vi, test } from 'vitest';
import * as userService from './userService';
test('fetches and displays user data', async () => {
// Mocking happens where you expect it to
vi.spyOn(userService, 'getUser').mockResolvedValue({ id: 117, name: 'John' });
// Rest of test...
});
At first glance, both tests have a similar shape. The main difference is the vi.mock
call in the root scope in the first example and the star import in the second. However, beyond these superficial differences there is a bigger behavioral one. vi.mock
is affecting every test in the file, while vi.spyOn
is local to your test. It’s manageable with one test, but quickly becomes a liability as your file grows.
The Problem of Module-Wide Replacement
vi.mock
always replaces the entire module, even parts you might want to keep real. This behavior can be surprising in larger test files, especially when the vi.mock
call has scrolled off screen.
Consider this simplified (and yes, slightly contrived) scenario:
import { vi, test } from 'vitest';
import { getUser } from './userService';
vi.mock('./userService');
test('getUser test 1', () => {
vi.mocked(getUser).mockResolvedValue({ id: 1, name: 'John' });
// Tests here...
});
test('getUser test 2', () => {
vi.mocked(getUser).mockResolvedValue({ id: 2, name: 'Jane' });
// More tests...
});
We start with two tests mocking the userService
module. Later, we modify this test file to add a new test for the gravatarUrl
method from the same module:
// Later, we add a new function from the same module
import { getUser, gravatarUrl } from './userService';
vi.mock('./userService');
// ... existing tests
test('gravatarUrl', () => {
// This will fail because gravatarUrl is undefined in the mocked module
expect(gravatarUrl('user@example.com')).toBe('https://gravatar.com/avatar/b4c9a...');
});
Suddenly, gravatarUrl
is always returning undefined
! While this example is simplified, it illustrates a real problem: in long test files, it's not always obvious that vi.mock('./userService')
is silently replacing everything, even code that was never meant to be mocked.
The problem gets worse as your test file grows. Different tests may rely on different aspects of the same module. vi.mock
introduces implicit coupling between them. As the number of tests increases, you end up with a tangled web of mock state that’s hard to reason about and even harder to maintain.
Remember: that innocent-looking vi.mock
call has global effects. Because it’s hoisted to the top of the file (before any imports!), it can lead to a confusing and brittle execution order. Using vi.mocked
can help annotate intent, but it can’t undo the global scope of the mock itself.
With vi.spyOn
, you only mock what you need, leaving the rest of the module intact:
import * as userService from './userService';
test('getUser test', () => {
const getUserSpy = vi.spyOn(userService, 'getUser')
.mockResolvedValue({ id: 1, name: 'John' });
// Test logic...
});
test('gravatarUrl', () => {
// This works normally because we didn't mock it
expect(userService.gravatarUrl('user@example.com')).toBe('https://gravatar.com/avatar/b4c9a...');
});
Each test only affects what it explicitly mocks, making tests more isolated and predictable.
Type Safety: Less Accounting Work, More Reliable Tests
One of the most compelling reasons to favor vi.spyOn
is type safety. When mocking an entire module with vi.mock
, TypeScript loses track of the type contract for that module's functions, requiring you to use the vi.mocked
helper.
import { add } from './calculator';
vi.mock('./calculator');
test('addition works', () => {
// TypeScript will complain without vi.mocked
add.mockReturnValue(5); // ❌ Error: Property 'mockReturnValue' does not exist on type...
// This makes TypeScript happy, but it's a kludge
vi.mocked(add).mockReturnValue(5);
expect(add(2, 3)).toBe(5);
});
The vi.mocked
API exists to patch over this exact problem. And it kinda works? It infers the original types of the mocked module, which can save time. But I think it's a bit of a kludge. It relies on the developer to remember to use it everywhere. At its core, it’s a type assertion: you’re telling TypeScript, “Trust me, this is a mock function.” But that trust is fragile, if you forget, TypeScript won’t have your back. Worse, if someone later removes or moves the vi.mock
call, your vi.mocked
code will quietly stop working, and TypeScript won’t say a word, because past you already swore everything was fine.
Now compare with vi.spyOn
:
import * as calculator from './calculator';
test('addition works', () => {
const spy = vi.spyOn(calculator, 'add').mockReturnValue(5);
// TypeScript knows exactly what's going on here
expect(calculator.add(2, 3)).toBe(5);
// TypeScript will even catch errors in mock implementations
vi.spyOn(calculator, 'add').mockReturnValue("not a number"); // ❌ Type error!
});
With vi.spyOn
, TypeScript enforces both parameter types and return types, catching errors before your tests even run. This means fewer bugs and faster test development cycles.
The vi.mock + vi.requireActual Anti-pattern
If you've been using vi.mock
for a while, you've probably run into this situation: you want to mock just one function from a module while keeping the rest of it real. So you reach for the old trick:
// The dreaded mock + requireActual pattern
vi.mock('./utils', () => {
const actual = vi.requireActual('./utils');
return {
...actual,
formatDate: vi.fn().mockReturnValue('2025-03-15')
};
});
import * as utils from './utils';
test('uses formatted date', () => {
// Your test logic...
});
This is a red flag! It’s complex, fragile, and easy to get wrong. If the formatDate
function signature changes, this test might silently break in confusing ways.
Compare with the vi.spyOn
approach:
// Clean and clear with vi.spyOn
import * as utils from './utils';
test('uses formatted date', () => {
const formatSpy = vi.spyOn(utils, 'formatDate').mockReturnValue('2025-03-15');
// Your test logic...
});
This is cleaner, safer, and TypeScript has your back. If the API changes, you’ll know. No spread hacks, no runtime indirection, just one line where you say exactly what you want to override.
So if you ever catch yourself reaching for vi.requireActual
, take it as a sign: you're fighting your tools. Switch to vi.spyOn
instead.
Live Bindings and Closures: The Edge Cases
I love an exception that proves the rule and with spyOn
that is live bindings.
I’ll admit it: in this specific case, vi.mock
has the edge on usability. But once you understand what’s happening, it’s easy to fix.
vi.spyOn
works by modifying an ES module’s live binding, a concept where the imported value stays linked to the original export. When the exporting module updates that value, the change is visible to the importer. That’s what makes spying possible in the first place.
But there’s a catch: if your code saves a direct reference to an imported function, like when wrapping it in a higher-order function, the spy won’t affect it. You’ve captured a value, not a binding.
This shows up a lot with utilities like debounce
or throttle
:
// In your module
import { debounce } from 'lodash';
import { expensiveOperation } from './operations';
// This won't be affected by spying on operations.expensiveOperation
const debouncedOperation = debounce(expensiveOperation, 315);
export function performOperation() {
return debouncedOperation();
}
Here, debouncedOperation
has closed over the original function. So if you spy on operations.expensiveOperation
, it won’t change what debouncedOperation
does.
The fix? Use a closure to defer the reference:
// Better approach
import { debounce } from 'lodash';
import { expensiveOperation } from './operations';
// This preserves the live binding
const debouncedOperation = debounce(() => expensiveOperation(), 300);
export function performOperation() {
return debouncedOperation();
}
Now, when you spy on operations.expensiveOperation
, the debounced version respects the override, because it accesses the function through the live binding.
Tips for Using vi.spyOn
To get the most out of vi.spyOn
, follow these best practices:
- Restore your spies after each test:
afterEach(() => {
vi.restoreAllMocks(); // Clean up all spies at once
});
This prevents test bleed and ensures each test starts fresh. In most projects, you’ll want to add this to your global test setup file and forget about it.
- Import modules as namespaces:
import * as userService from './userService';
This gives you a reference to the module object, which you need for vi.spyOn
.
- Keep spy creation close to where it's used:
test('specific test case', () => {
const spy = vi.spyOn(module, 'method').mockImplementation(() => 'mock value');
// Test that directly uses this mock
});
This maintains the locality principle and makes tests easier to understand.
When vi.mock Is Appropriate (The Exception)
I'll admit it, vi.mock
does have its place. Sometimes you genuinely want to turn off a dependency completely, especially when it introduces noise or side effects that aren’t relevant to the behavior under test.
Good candidates include:
- Logging libraries that clutter your test output
- Analytics trackers that fire background events
- Expensive but non-essential computations that slow down your tests (yes, I’m looking at you,
popper.js
)
For example:
// An appropriate use of vi.mock
vi.mock('./logger');
The key is that these dependencies aren’t what you’re testing. They’re distractions, runtime noise that you want to eliminate so your test stays focused and fast.
Conclusion
vi.mock
has its place, but it belongs deep in the back of the toolbox. For most situations, vi.spyOn
is the better default. It gives you:
- Better type safety and compile-time error checking
- Clearer, more predictable test structure
- Fine-grained control without wiping out the whole module
- Freedom from
vi.requireActual
hacks and global side effects
So the next time you're about to reach for vi.mock
, stop and ask:
Am I trying to disable this dependency entirely, or just control its behavior?
If it's the latter, give vi.spyOn
a chance. Your future self, and your teammates, will thank you when when it’s time to debug.