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

`vi.mock` Is a Footgun: Why `vi.spyOn` Should Be Your Default
Crash test dummies ready to mock out actual humans.

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:

  1. 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.

  1. Import modules as namespaces:
import * as userService from './userService';

This gives you a reference to the module object, which you need for vi.spyOn.

  1. 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:

  1. Better type safety and compile-time error checking
  2. Clearer, more predictable test structure
  3. Fine-grained control without wiping out the whole module
  4. 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.

Subscribe to Laconic Wit

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe