DEV Community

Cover image for Two shades of mocking a function in Vitest
Maya Shavin πŸŒ·β˜•οΈπŸ‘
Maya Shavin πŸŒ·β˜•οΈπŸ‘

Posted on • Originally published at mayashavin.com

Two shades of mocking a function in Vitest

Mocking is the approach of replacing a real object with a fake object that simulates the original behavior, hence allowing us to test our code in isolation (unit test), without having to worry about instability may arised by the behavior of the external dependencies. In this article, we will discuss two different ways of mocking a external function call in Vitest, using vi.fn() and vi.spyOn() APIs, and when to use which.

Table of contents

Prerequisites

You should have a Vite project set up and running. The recommended way is to initialize your project with Vite as its bundle management tool, using the command npm create vite@latest.

You need to install Vitest as the unit testing framework for our tests, by running the following command:

yarn add -D vitest
Enter fullscreen mode Exit fullscreen mode

Additionally, if you are testing your front-end application, you can install the following tools to help you with any framework-specific unit tests:

In vitest.config.js (or vite.config.js for Vite projects), we add the following test object to enable Vitest APIs as globally available in our tests:

//...
export default defineConfig({
  test: {
    global: true,
    environment: 'jsdom', //this is optional, only when you are testing DOM related functionalities
  },
})
Enter fullscreen mode Exit fullscreen mode

If you are writing your tests in TypeScript, you can also add the following tsconfig.json file to provide the types for Vitest APIs to TypeScript type checker:

{/** tsconfig.json */
  "compilerOptions": {
    "types": [["vitest/globals"]]
  }
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we add a new test:unit command to the package.json file. This command will find and run the unit tests from the src/ as its root folder, as show below:

"scripts": {
    "test:unit": "vitest --root src/",
}
Enter fullscreen mode Exit fullscreen mode

With this setup, we are ready to explore different ways of mocking external function call with Vitest, starting with our testing scenario.

Our test scenario

We have a function fetchData that makes a network request to an API and returns the data, as follows:

// fetchData.js
export async function fetchData() {
  const response = await fetch(
    'http://exploringvue.com/.netlify/functions/pizzas'
  );
  const data = await response.json();
  return data;
}
Enter fullscreen mode Exit fullscreen mode

We want to test this function, and we need to mock the fetch call to avoid making a real network request, potentially harming the quality and the scope of the test scenarios.

There are many ways of mocking a fetch call in Vitest. One way is to use vi.fn(), which we will discuss next.

Mocking a function using vi.fn()

fetch is a global method that helps to retrieve resources asynchronously from the server. It is available in both server side (Nodejs API) and client side (Web API) environments. Our testing environment is Nodejs, hence the fetch method is available as part of global object, as global.fetch and not window.fetch.

vi.fn() is the most straightforward approach to mock a function call in Vitest. It returns a new mock instance (or spy instance) that we can use to replace the original function, as shown below:

import { vi } from 'vitest';
import { fetchData } from './fetchData';

const mockedImplementation = () => Promise.resolve({ 
  json() { 
    return { data: 'mocked data'}
  } 
})

describe ('fetchData', () => {
  global.fetch = vi.fn(mockedImplementation);

  it('should return the mocked data', async () => {
    const data = await fetchData();
    expect(data).toEqual({ data: 'mocked data' });
  });
});
Enter fullscreen mode Exit fullscreen mode

In the above code, we are replacing the original global.fetch method with a new mock function that returns a resolved promise with the data { data: 'mocked data' }. This way, we can test the fetchData function without making a real network request.

vi.fn() also accepts no arguments, which means it returns a new mock function that does nothing, such as global.fetch = vi.fn(), and uses mockImplementation() to provide a new mock implementation:

describe ('fetchData', () => {
  global.fetch = vi.fn();
  global.fetch.mockImplementation(mockedImplementation);
  //...
});
Enter fullscreen mode Exit fullscreen mode

This approach can be handy when we only want to fast-mock a function call's return value without any side effects to the test results.

However, there is a problem with the mock approach with vi.fn(). In our current code, we are losing the original implementation of the global.fetch method, and we won't be able to restore it after the test. This can potentially cause test-contamination, where the mock function from one test may affect the result of another test, which uses the original fetch. To solve this issue, we should save the original implementation of the global.fetch method before all the tests run within the same test suite:

import { vi } from 'vitest';

describe ('fetchData', () => {
  let originalFetch;

  beforeAll(() => {
    originalFetch = global.fetch;
    global.fetch = vi.fn(mockedImplementation);
  });

  // ...tests
});
Enter fullscreen mode Exit fullscreen mode

And after running all the test cases, we restore the global.fetch method to its original implementation:

describe ('fetchData', () => {
  // ...

  afterAll(() => {
    global.fetch = originalFetch;
  });
});
Enter fullscreen mode Exit fullscreen mode

The above code will ensure the global.fetch function will be restored after we finish with the fetchData test suite and won't affect other relevant tests. Altenatively, there is a better way to mock and spy a function while maintains the original implementation and reduces the chance for test-contamination, with lesser code. We will discuss this in the next section.

Mocking a global function using vi.spyOn()

Similar to vi.fn(), vi.spyOn() also return a Vitest spy instance but we don't need to replace the original global.fetch with it. vi.spyOn() accepts two arguments:

  • The object containing the method (global), and
  • The method name to spy on (fetch).
describe ('fetchData', () => {
  const fetchSpy = vi.spyOn(global, 'fetch');

  // ...tests
});
Enter fullscreen mode Exit fullscreen mode

And then we can mock the implementation of the fetch method using the mockImplementation method of the spy instance:

describe ('fetchData', () => {
  const fetchSpy = vi.spyOn(global, 'fetch');

  fetchSpy.mockImplementation(mockedImplementation);

  // the tests are the same
});
Enter fullscreen mode Exit fullscreen mode

With this approach, we don't need to save the original implementation of the global.fetch method manually. Instead, we can use the mockRestore method of the spy instance within afterAll hook to restore the original implementation afterwards:

describe ('fetchData', () => {
  //...

  afterAll(() => {
    fetchSpy.mockRestore();
  });
});
Enter fullscreen mode Exit fullscreen mode

This is my preferred way of mocking a external function call in Vitest, as it is more readable and maintainable, in comparison to the vi.fn() approach. In fact, vi.spyOn() returns a spy instance whose implementation defaults to the original method, and we can change it per test case. This approach is really handy for both integrating tests and unit tests, as it allows us to spy on a specific dependency of the target code, and to assert the interaction between them.

So, when should we use vi.fn() and vi.spyOn()?

vi.fn vs vi.spyOn - When to use which?

While both vi.fn() and vi.spyOn() can be used to mock a function call in Vitest, and behind the scenes, vi.spyOn is a wrapper around vi.fn, they are designated for different use cases, as shown in the table below:

vi.fn() vi.spyOn()
When you want to replace the original function with a new mock function When you want to spy on the original function and mock its implementation per scenario
You need to save the original implementation manually There is a built-in method for restore the original function
More suitable for fast-mocking a function call More suitable for mocking a function call with a side effect
For unit testing the function's implementation, not its interaction with other functions For both unit and integration tests

Summary

We have learned how to mock a function call, such as the global fetch in Vitest using vi.fn() and vi.spyOn(). We have also discussed the differences between these two approaches and when to use which. Mocking is a powerful tool for isolating the dependencies of the code we are testing, or for asserting the interaction between them. In the next article, we will discuss further on different mocking scenarios, both for unit and integration tests, and the best practices for asserting mocks and cleaning up after mocking.

πŸ‘‰ Learn about Vue 3 and TypeScript with my new book Learning Vue!

πŸ‘‰ If you'd like to catch up with me sometimes, follow me on X | LinkedIn.

Like this post or find it helpful? Share it πŸ‘‡πŸΌ πŸ˜‰

Top comments (1)

Collapse
 
silvesterwali profile image
silvesterwali

just amazing . thanks for great article