Module and environment variable stubbing for efficient testing in Vitest
In this article, we will continue discussing another approach of mocking, such as an external module, using vi.mock()
and how we can stub different values for an environment variable using vi.stubEnv()
.
Table of contents
- Table of contents
- Our test scenario
- Mocking an external module with vi.mock()
- Mocking an environment variable with vi.stubEnv()
- Restore mocked and stubbed objects
- Summary
Our test scenario
In this example, we have a backend function - getPizzas
that:
- Fetches Pizza data from an external API,
- Filters the response according to a query received (using imported method
filterItems
from/utils
), - Returns the first 10 filtered results.
/** src/getPizzas.js */
import { filterItems } from './utils'
export const getPizzas = async (query) => {
const API_URL = 'http://exploringvue.com/.netlify/functions'
const response = await fetch(`${API_URL}/pizzas`)
.then((res) => res.json())
.catch((err) => {
throw new Error("Failed to fetch pizzas");
});
const results = filterItems(response, "title", query);
return {
data: results.slice(0, 10),
hasNextPage: results.length > 10,
};
};
We want to test the above function in two levels:
- In isolation from the
filterItems
function, and the external call to the pizza API. - Integration test without mocking the external call and with an a different API URL for testing purpose only.
Let's start with the first level, where we need to mock all the dependencies using vi.mock()
, vi.spyOn()
and vi.fn()
.
Mocking an external module with vi.mock()
vi.mock()
allows us to mock an external imported module (not an API or a global object). It accepts two arguments: the module path and a factory function that returns the mocked module instance.
To mock our external module that contains the filterItems
function, we can use vi.mock()
as follows:
/** getPizzas.test.js */
import { vi } from 'vitest';
vi.mock('./utils', async () => {
return {
filterItems: vi.fn().mockImplementation(() => [{ id: 1, title: 'Pizza 1' }]),
}
})
describe('getPizzas', () => {
//...
});
In the above code, we passed the module path ./utils
and used vi.fn()
to mock the filterItems
function in the returned module instance. However, with this implementation, we are replacing the entire module with an instance that contains only the mocked filterItems
method, which may not be the desired behavior in case ./utils
contains additional methods.
Fortunately, the factory function receives an asynchronous helper - importOriginal
as its input, which gives us the original module, hence allowing us to keep the module structure, as shown below:
vi.mock('./utils', async (importOriginal) => {
const originalUtils = await importOriginal();
return {
...originalUtils,
filterItems: vi.fn().mockImplementation(
() => [{ id: 1, title: 'Pizza 1' }]
),
}
})
With this code, we have replaced ./utils
module with a new instance that contains both the original module methods and the mocked filterItems
method.
Note that vi.mock()
is hoisted to the top of the file, and Vitest will always execute it first, which means the following code will not work:
const mockedFilteredItems = vi.fn().mockImplementation(() => []);
vi.mock('./utils', async (importOriginal) => {
const originalUtils = await importOriginal();
return {
...originalUtils,
filterItems: mockedFilteredItems //ERROR: mockedFilteredItems is not defined
}
})
To avoid the above error, and enable the ability to mock the implementation of filterItems
dynamically per test, we can perform the following as alternative:
- Call
vi.mock()
without a factory function - Import the module using
import
and then usevi.mocked()
to get the mocked method from the module. - Change its implementation using
mockImplementation()
per test.
The below code demonstrates this approach:
//1. Import the module
import * as utils from './utils';
//2. Mock the module
vi.mock('./utils');
describe('getPizzas', () => {
it('should return pizzas without next page', async () => {
//3. Get the mocked method instance
const mockedFilterItems = vi.mocked(utils.filterItems);
//4. Change the mocked implementation
mockedFilterItems.mockImplementation(
() => [{ id: 1, title: 'Pizza 1' }]
);
//5. Test the function
//...
});
});
Great. But we still can't run the test because we haven't mocked the external API call with fetch
yet. Unfortunately, vi.mock()
won't help us in mocking global
module. Instead, we can mock fetch
by using vi.fn() or vi.spyOn(), from the previous blog post.
Once we have mocked the fetch
call, we can now complete the first test for the getPizzas
function in isolation, as follows:
import utils from './utils';
vi.mock('./utils');
describe('getPizzas', () => {
const fetchSpy = vi.spyOn(global, 'fetch');
it('should return pizzas without next page', async () => {
const mockedFilterItems = vi.mocked(utils.filterItems);
const mockResponse = [{ id: 1, title: 'Pizza 1' }];
mockedFilterItems.mockImplementation(
() => mockResponse
);
fetchSpy.mockResolvedValue({
ok: true,
json: async () => mockResponse,
});
const result = await getPizzas();
expect(result.data.length).toBe(1);
expect(result.hasNextPage).toBe(false);
});
});
As you can see in the above test, we have mocked both the filterItems
function and the fetch
call. In reality, this may not be the best approach. As vi.mock()
receives the module path as its first argument, hence if the module path changes, our tests may break.
Also, filterItems
is a simple function, and we could test it together with getPizzas
without mocking. We should use vi.mock()
only when we need to mock an external module that contains complex logic or side effects. Otherwise, it's recommended to use vi.fn()
or vi.spyOn()
to mock the function directly.
At this point, we explored writing unit test using vi.mock()
for getPizzas()
. Next, we will discuss how to write an integration test for the same function, where we will mock the external API path, which is an environment variable.
Mocking an environment variable with vi.stubEnv()
Let's get back to our implementation of getPizzas
, and replace the static API URL with an environment variable API_URL
:
/** getPizzas.js */
import { filterItems } from './utils'
export const getPizzas = async (query) => {
const API_URL = process.env["API_URL"];
//...
};
When we need to test the getPizzas
function with a different API URL, we can use vi.stubEnv()
to mock this environment variable as follows:
import { vi } from 'vitest';
describe('getPizzas', () => {
it('returns a list of pizzas', async () => {
vi.stubEnv('API_URL', 'http://localhost:3000/testdata') //the target testing URL
//Assertion as normal
//...
});
});
Assuming that http://localhost:3000/testdata
is a testing URL that returns a list of pizzas, we have just completed the first integration test for getPizzas
. This approach allows us to test the function with different API URLs without changing the actual environment variable's value.
Lastly, rule of thumbs: anything we mock/stub, we may need to restore it before/after each test. We will do it next.
Restore mocked and stubbed objects
To restore the all the stubbed environment variables, we can trigger vi.unstubAllEnvs()
after each test run, and in the below code:
describe('getPizzas', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
//tests...
});
Alternatively, we can achieve the same by setting unstubEnvs
to true
in the vitest.config.js
file.
/** vitest.config.js */
export default defineConfig({
test: {
//..
unstubEnvs: true,
},
})
This way, we can ensure that Vitest restores all environment variables before each test run.
For mocked module by vi.mock()
, the best way is to set mockReset: true
in the vitest.config.js
file as follows:
/** vitest.config.js */
export default defineConfig({
test: {
//..
mockReset: true,
},
})
Summary
We have learnt how to mock an external module using vi.mock()
and stub an environment variable using vi.stubEnv()
in Vitest, as well as when it is best for. We also learned different ways to restore the mocked and stubbed objects. With these techniques, we can write efficient tests for our functions, ensuring that they are isolated and independent of external dependencies when needed.
๐ Learn about Vue 3 in 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? Buy me a coffee โ ๐๐ผ