JavaScript Testing

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!

A

Alex Miller

Senior Frontend Engineer specializing in test-driven development and modern JavaScript frameworks.

7 min read4 views

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

Choosing the Right Mocking Tool
Featurevi.mockvi.spyOnRecommendation
ScopeEntire ModuleSpecific method on an objectUse vi.spyOn for targeted changes.
HoistingYes (automatic)Novi.spyOn follows natural code flow, reducing confusion.
Original ImplementationReplaced entirelyPreserved by default (can be overridden)vi.spyOn is better for preserving other module functions.
Use CaseMocking 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.
Cleanupvi.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.