JavaScript Testing

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.

E

Elena Petrova

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

7 min read3 views

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.

Comparison of Vitest Mocking Strategies
Featurevi.mockvi.spyOnWinner / Best For...
ScopeEntire ModuleSpecific Method on an Objectvi.spyOn for precision
Test BrittlenessHigh. Tightly coupled to module structure and file paths.Low. Resilient to internal refactoring of the dependency.vi.spyOn for maintainability
SetupRequires 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
RealismLow. The entire dependency is fake.High. The original module is used, with only one method intercepted.vi.spyOn for integration-style unit tests
Use CaseMocking 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
CleanupAutomatically 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 a NotificationService. You can spy on notificationService.sendEmail to confirm an email is sent when a payment succeeds, while allowing the rest of the NotificationService 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 or jsonwebtoken, 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 for vi.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.