Vitest Mocking Deep Dive: vi.mock vs. vi.spyOn
Testing JavaScript applications often involves isolating the code under test from its dependencies. This isolation is achieved through mocking, a technique that replaces real dependencies with controlled substitutes. While essential, the distinction between Vitest's primary mocking utilities—vi.mock and vi.spyOn—can be a source of confusion. This article will clarify their specific use cases, complete with practical code examples.
Understanding vi.mock: Replacing Entire Modules
vi.mock is your go-to function when you need to replace an entire module or specific named exports within a module. This is particularly useful for external dependencies like API clients, database connectors, or utility libraries that perform side effects (e.g., network requests, file system operations) you want to control during a test.
When vi.mock is called, Vitest intercepts calls to the specified module throughout the test file. It effectively swaps out the real module for your mock implementation before the module under test is even imported.
Consider a dataService.js that fetches data:
// src/dataService.js
import axios from 'axios';
export const fetchData = async (id) => {
const response = await axios.get(`https://api.example.com/data/${id}`);
return response.data;
};
export const postData = async (payload) => {
const response = await axios.post('https://api.example.com/data', payload);
return response.data;
};
To test a component or function that uses fetchData without making actual network calls, you'd mock axios:
// test/dataService.test.js
import { describe, it, expect, vi } from 'vitest';
import { fetchData } from '../src/dataService';
// Mock the entire 'axios' module
vi.mock('axios', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: { id: 1, name: 'Mocked Data' } })),
post: vi.fn(() => Promise.resolve({ data: { status: 'success' } }))
}
}));
describe('fetchData', () => {
it('should fetch data using mocked axios', async () => {
const result = await fetchData(1);
expect(result).toEqual({ id: 1, name: 'Mocked Data' });
// Verify axios.get was called
expect(vi.mocked(axios).get).toHaveBeenCalledWith('https://api.example.com/data/1');
});
});
In this example, vi.mock('axios', ...) replaces the entire axios module. The factory function returns an object that becomes the mocked module. We mock axios.default because axios is often imported as a default export. vi.fn() creates a mock function, allowing us to control its return value (mockImplementation, mockResolvedValue, mockReturnValue) and inspect its calls (toHaveBeenCalledWith). vi.mocked(axios) is a type helper to ensure TypeScript knows axios is a mocked object.
Sometimes, you only need to mock a specific export from your own module, not the entire module. You can use a partial mock:
// test/partialMock.test.js
import { describe, it, expect, vi } from 'vitest';
import * as dataService from '../src/dataService'; // Import everything to access other exports
vi.mock('../src/dataService', async (importOriginal) => {
const actual = await importOriginal(); // Get the actual module exports
return {
...actual, // Spread all actual exports
fetchData: vi.fn(() => Promise.resolve({ id: 99, name: 'Partially Mocked' })) // Override only fetchData
};
});
describe('dataService partial mock', () => {
it('should use the mocked fetchData but real postData', async () => {
const fetched = await dataService.fetchData(123);
expect(fetched).toEqual({ id: 99, name: 'Partially Mocked' });
// If postData had a distinct side effect, we'd see it, or mock it separately if needed.
// Here, for demonstration, imagine postData is still the original function.
// expect(dataService.postData).toBeDefined(); // This would pass if not mocked
});
});
Here, vi.mock uses an async factory function. importOriginal() resolves to the actual module's exports, allowing us to selectively override fetchData while keeping postData (and any other exports) as their original implementations. This is crucial when you only want to isolate a specific function without affecting others in the same module.
Understanding vi.spyOn: Inspecting and Modifying Existing Methods
vi.spyOn is used when you want to observe or temporarily modify the behavior of an existing method on an existing object. It does not replace an entire module; rather, it wraps around a specific method, allowing you to track its calls, arguments, and return values, and even change its implementation for the duration of a test.
vi.spyOn is ideal for:
- Observing internal calls: Checking if a helper method was called by the public method being tested.
- Temporarily altering behavior: Making a method return a specific value or throw an error for a test case without affecting other methods on the same object or other modules.
Let's consider a UserService class with a dependency on a logger:
// src/userService.js
export const logger = {
log: (message) => console.log(`LOG: ${message}`),
error: (message) => console.error(`ERROR: ${message}`)
};
export class UserService {
constructor() {
this.users = [];
}
addUser(user) {
if (!user || !user.name) {
logger.error('Invalid user data provided');
return false;
}
this.users.push(user);
logger.log(`User ${user.name} added.`);
return true;
}
}
We want to test addUser and ensure logger.error is called with the correct message when invalid data is provided, without actually logging to the console:
// test/userService.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService, logger } from '../src/userService';
describe('UserService', () => {
let userService;
let errorSpy;
beforeEach(() => {
userService = new UserService();
// Spy on the 'error' method of the 'logger' object
errorSpy = vi.spyOn(logger, 'error');
// Restore the original implementation after each test to prevent interference
errorSpy.mockRestore(); // Important for clean tests
});
it('should add a user successfully', () => {
const user = { name: 'Alice' };
const result = userService.addUser(user);
expect(result).toBe(true);
expect(userService.users).toContainEqual(user);
expect(logger.log).toHaveBeenCalledWith('User Alice added.');
});
it('should log an error and return false for invalid user data', () => {
// Temporarily replace the implementation of logger.error for this test
errorSpy.mockImplementation(() => {}); // Do nothing
const result = userService.addUser(null);
expect(result).toBe(false);
expect(userService.users).toEqual([]); // No user should be added
expect(errorSpy).toHaveBeenCalledWith('Invalid user data provided');
});
});
Here, vi.spyOn(logger, 'error') creates a spy on the error method of the logger object. The beforeEach block ensures a fresh spy for each test and calls errorSpy.mockRestore(). This is crucial because vi.spyOn modifies the original method, and if not restored, subsequent tests might be affected. In the second test, errorSpy.mockImplementation(() => {}) overrides the error method's behavior, making it do nothing instead of logging to console.error. This allows us to test the logic without console pollution and verify the spy was called correctly.
When to Use Which?
-
Use
vi.mockwhen:- You need to replace an entire external module (e.g.,
axios, a database client). - You need to replace specific named exports of a module before it's imported by the code under test.
- The dependency is imported directly via
import X from 'module'orimport { Y } from 'module'and you want to control its behavior globally for a test file.
- You need to replace an entire external module (e.g.,
-
Use
vi.spyOnwhen:- You want to observe if a method on an already instantiated object (or a module's direct export) was called, with what arguments, and how many times.
- You need to temporarily change the implementation of an existing method on an object for a specific test scenario, without affecting other methods on that object or requiring a full module replacement.
- You're working with classes or objects where methods are called directly on instances.
Common Mistakes and Gotchas
-
vi.mockHoisting:vi.mockcalls are hoisted to the top of the file. This means they execute before anyimportstatements. If you try to define a mock after your imports, it won't work as expected. Always placevi.mockat the top level of your test file. - Forgetting
mockRestore()withvi.spyOn: Spies modify the actual object method. If you usemockImplementationormockReturnValueon a spy and don't callspy.mockRestore()(often in aafterEachorbeforeEachblock), the modified behavior will leak into subsequent tests, leading to flaky results. - Mocking too much: Over-mocking can lead to tests that don't reflect real-world behavior and become brittle. Mock only what's necessary to isolate the unit under test and control side effects.
- Misunderstanding Default Exports: When mocking modules with default exports (like
axios), remember to mock thedefaultproperty within your factory function (e.g.,default: vi.fn()). - Not using
vi.mockedfor TypeScript: When mocking modules with TypeScript, cast the mocked module usingvi.mocked(myModule)to get proper type inference for your mock functions, preventing TypeScript errors when accessing mock-specific methods liketoHaveBeenCalled.
Conclusion
Mastering vi.mock and vi.spyOn is fundamental for writing robust and reliable unit tests with Vitest. vi.mock allows you to control entire module dependencies, providing a clean slate for isolating your code. vi.spyOn, on the other hand, offers granular control over individual methods, enabling inspection and temporary behavior modification without broad module replacement. By understanding their distinct purposes and avoiding common pitfalls, you can write more effective and maintainable tests, ensuring the quality of your JavaScript applications.
Dive into your Vitest tests and apply these techniques to gain finer control over your testing environment!
Top comments (0)