JavaScript Testing

My #1 Vitest Regret: Trading vi.mock for vi.spyOn in 2025

A 2025 retrospective on why replacing `vi.mock` with `vi.spyOn` was my biggest testing mistake. Learn the crucial differences and save your test suite.

E

Elena Petrova

Principal Software Engineer specializing in test-driven development and modern JavaScript ecosystems.

6 min read4 views

Introduction: The Road to Regret

It’s early 2025, and I'm looking back at a decision our team made a year ago with a heavy heart. A decision that seemed so logical, so clean, so... modern. We decided to standardize on vi.spyOn over vi.mock for the majority of our Vitest unit tests. We celebrated the move, praising the apparent simplicity and directness. Today, I consider it my single biggest professional regret of the last year.

This isn't just a story of choosing the wrong tool. It's a cautionary tale about the subtle but profound difference between observing behavior and isolating it. We traded the bulletproof isolation of vi.mock for the convenient observation of vi.spyOn, and in doing so, we slowly and silently poisoned our entire test suite. Our tests became brittle, our refactoring process became a minefield, and our confidence in our code plummeted. This is the story of how we went wrong, and how we found our way back.

The Siren's Call: Why We Chose vi.spyOn

The appeal of vi.spyOn is undeniable. On the surface, it feels less destructive and more surgical than vi.mock. Instead of blowing away an entire module and replacing it with a fake, you're just... watching. You're placing a small, unobtrusive spy on a specific method of a real object.

Our rationale was simple:

  • Perceived Simplicity: The syntax vi.spyOn(object, 'methodName') is incredibly intuitive. It directly communicates the intent: "I want to know when this specific method is called."
  • Reduced Mocking Overhead: With vi.mock, you often have to mock out the entire module's exports, even if you only care about one function. vi.spyOn felt lighter and more focused.
  • "Closer to Reality": The argument was that by using the real module and only spying on a method, our tests were running in an environment closer to production. This, we believed, would catch more realistic bugs. As we learned, this was a dangerous fallacy.

This line of thinking led us to favor vi.spyOn for almost everything, from checking if a service method was called to verifying DOM manipulations. It worked beautifully at first, but the cracks in the foundation were already forming.

vi.mock vs. vi.spyOn: A Fundamental Divide

To understand our mistake, you must first understand the philosophical difference between these two APIs. They are not interchangeable; they serve fundamentally different purposes.

vi.mock: The Isolator

vi.mock operates at the module level. When you write vi.mock('./modules/apiService'), Vitest intercepts the module import. It never loads the real apiService.js file. Instead, it provides a blank slate—an auto-mocked object where every export is a mock function (a spy). Its primary goal is true unit test isolation.

You use vi.mock when you want to say: "I am testing Component A, and I do not trust or care about the real implementation of its dependency, Module B. I only care that Component A interacts with Module B's public contract correctly."

vi.spyOn: The Observer

vi.spyOn operates on an existing object's method. It does not prevent the module from being loaded. The real code is there, and the real object exists. vi.spyOn then reaches into that object and wraps a method with a spy, allowing you to observe calls or provide a temporary fake implementation. Its primary goal is observation or partial mocking of real objects.

You use vi.spyOn when you want to say: "I need this real object, Module B, to exist and function, but for this one specific test, I want to watch or override what happens when methodX is called."

Comparison Table: vi.mock vs. vi.spyOn at a Glance

Comparing Core Testing Philosophies
Aspect vi.mock vi.spyOn
Scope Entire Module Specific method on an existing object
Primary Goal Isolation. Prevents dependency's code from running. Observation. Watches a method on a real, running object.
Test Type Ideal for pure Unit Tests. Leans towards Integration Tests.
Coupling Low. Tests are coupled only to the dependency's public API. High. Tests are coupled to the dependency's implementation.
Refactoring Resilience High. Internal changes to mocked modules don't break tests. Low. Internal changes can easily break tests that spy on methods.
Common Use Case Mocking external dependencies like API clients, databases, or utility libraries. Verifying calls to global objects (e.g., window.fetch) or methods within the same class.

The Ticking Time Bomb: How vi.spyOn Eroded Our Test Suite

Our test suite felt fine for months. Then, the problems started. They were small at first—a test failing randomly after a seemingly unrelated change. Then it became an epidemic.

The Domino Effect of Brittle Tests

Imagine a NotificationService that uses a LoggerService internally.

// LoggerService.js
export const logger = {
  log: (message) => console.log(message)
};

// NotificationService.js
import { logger } from './LoggerService';
export function notify(user, message) {
  // ... some logic
  logger.log(`Notifying ${user}: ${message}`);
  // ... more logic
}

With our vi.spyOn approach, a test for a component that calls notify looked like this:

import * as NotificationService from './NotificationService';

it('should notify the user', () => {
  const notifySpy = vi.spyOn(NotificationService, 'notify');
  // ... code that triggers the notification
  expect(notifySpy).toHaveBeenCalledWith('Elena', 'Your report is ready.');
});

This seems fine. But what happens when a developer refactors LoggerService to be asynchronous? The logger.log method now returns a promise. Suddenly, our notify function becomes async. The test above doesn't break. But another test, one that spies on logger.log directly, now fails because it's not handling the async nature. Worse, the implementation of NotificationService might now have subtle race conditions we aren't testing for because we're not truly isolating it. We're testing a component that relies on a real, and now changed, `NotificationService`.

With vi.mock, this problem is impossible. We would have mocked NotificationService entirely, and our test would only verify that our component called it correctly. The internal changes to the service would be irrelevant to our component's unit test.

Unseen Coupling and The Refactoring Nightmare

The biggest issue was coupling. Our tests became intimately aware of the implementation details of our dependencies. We weren't just testing the contract (e.g., "call the `notify` function"); we were testing the side effects of the real implementation.

When the time came to refactor NotificationService, it was a disaster. Changing its internal logic, even without altering its public function signature, caused a cascade of failures in completely unrelated component tests. Why? Because those tests relied on the real service, and its real behavior had changed. Our tests had become integration tests in disguise, and we were paying the price in maintenance overhead.

The Right Tool for the Job: When vi.spyOn Still Shines

This is not a complete condemnation of vi.spyOn. It is a powerful tool when used correctly for its intended purpose: observation. We've since defined clear guidelines for its use:

  • Testing calls to global objects: When you need to check if window.alert or console.error was called, spying is perfect. You can't (and shouldn't) mock the entire window object.
  • Testing methods on the same object: If you're testing a class method that calls another public method on the same class instance, vi.spyOn(instance, 'otherMethod') is a legitimate and useful pattern.
  • Minimal, targeted overrides: In a complex integration test, you might need a mostly real object but need to stub out one problematic method (e.g., one that makes a real network request). A spy with .mockImplementation() is a good choice here.

Our Path Back to Stability: Embracing vi.mock Again

The recovery process was painful but necessary. We instituted a new philosophy: "Mock external dependencies by default."

For any unit test of a module 'A' that imports a module 'B', we now start with vi.mock('./moduleB'). This forces the developer to think about the contract between A and B, not the implementation of B. Our tests are now more resilient, faster, and truly test one unit of logic at a time.

Our tests for the component using NotificationService now look like this:

import { notify } from './NotificationService';
import { renderComponent } from './test-utils';

vi.mock('./NotificationService');

it('should call the notify service on button click', () => {
  const { user } = renderComponent();
  // ... simulate button click
  expect(notify).toHaveBeenCalledWith('Elena', 'Your report is ready.');
});

This test is now completely decoupled from the internals of NotificationService. The service could be rewritten in Rust compiled to WASM for all this test cares. As long as it exports a function named notify that gets called with the correct arguments, the test passes. This is the stability we lost and have now reclaimed.