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.
Elena Petrova
Principal Software Engineer specializing in test-driven development and modern JavaScript ecosystems.
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
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
orconsole.error
was called, spying is perfect. You can't (and shouldn't) mock the entirewindow
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.