DEV Community

Manas Joshi
Manas Joshi

Posted on

Vitest Mocking: vi.mock vs vi.spyOn Explained

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;
};
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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
  });
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Observing internal calls: Checking if a helper method was called by the public method being tested.
  2. 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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.mock when:

    • 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' or import { Y } from 'module' and you want to control its behavior globally for a test file.
  • Use vi.spyOn when:

    • 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

  1. vi.mock Hoisting: vi.mock calls are hoisted to the top of the file. This means they execute before any import statements. If you try to define a mock after your imports, it won't work as expected. Always place vi.mock at the top level of your test file.
  2. Forgetting mockRestore() with vi.spyOn: Spies modify the actual object method. If you use mockImplementation or mockReturnValue on a spy and don't call spy.mockRestore() (often in a afterEach or beforeEach block), the modified behavior will leak into subsequent tests, leading to flaky results.
  3. 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.
  4. Misunderstanding Default Exports: When mocking modules with default exports (like axios), remember to mock the default property within your factory function (e.g., default: vi.fn()).
  5. Not using vi.mocked for TypeScript: When mocking modules with TypeScript, cast the mocked module using vi.mocked(myModule) to get proper type inference for your mock functions, preventing TypeScript errors when accessing mock-specific methods like toHaveBeenCalled.

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)