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
- Table of contents
- Prerequisites
- Our test scenario
- Mocking a function using vi.fn()
- Mocking a global function using vi.spyOn()
- vi.fn vs vi.spyOn - When to use which?
- Summary
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
Additionally, if you are testing your front-end application, you can install the following tools to help you with any framework-specific unit tests:
- JSDOM as the DOM environment for running component unit tests.
- React Testing Library as the React testing utilities or Vue Test Utils for Vue applications.
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
},
})
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"]]
}
}
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/",
}
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;
}
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' });
});
});
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);
//...
});
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
});
And after running all the test cases, we restore the global.fetch
method to its original implementation:
describe ('fetchData', () => {
// ...
afterAll(() => {
global.fetch = originalFetch;
});
});
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
});
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
});
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();
});
});
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)
just amazing . thanks for great article