I'm using Svelte stores in this post as an example, but the principle applies to any function or variable you want to mock with
vitest
, with any js framework.
Imagine this...
You've just refactored some complex code into a killer custom Svelte store that encapsulates some database calls and other logic. You can't wait to get this merged, but you know without tests the PR is getting sent straight back.
When it comes to unit testing some of your components that are using this new store, you only want to put the component under test, so you create a simple mock for your store.
Let's say your component uses a current project id...
// Doesn't work 🚫
const mockStore = writable<number>(1);
vi.mock('$lib/stores/projects', () => ({
currentProjectId: mockStore
}));
test('first test' => {
// ...
})
test('second test' => {
// change mock
mockStore.set(5)
// ...
})
However, vitest
gives you an error.
If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file.
All you've done is create a simple mock, right?
Before you start wondering if you can convince your teammates that there are legitimate reasons you cannot test this code, or you'll do the tests in a follow-up PR 🤨 let's look at how vitest
is using mock and hoisting.
vi.mock
The mock function has some nuances - regardless of where you have created your mock, it is always hoisted to the top of the file.
JavaScript Hoisting refers to the process whereby the interpreter appears to move the declaration of functions, variables, classes, or imports to the top of their scope, prior to execution of the code.
Whether your mock function is inside a describe
block, in the beforeAll
or inside a test,
it doesn't matter to vitest
- it is getting sent to the top of the file.
This has some side effects you need to be aware of...
Re-mocking won't work
If you've not done much mocking before, you'd be forgiven for thinking something like this will work - re-calling the mock function at the beginning of each test.
// this test may fail due to conflicting mock 🚫
test('first test' => {
vi.mock('$lib/stores/projects', () => ({
currentProjectId: writable<number>(1),
}));
// ...
})
test('second test' => {
vi.mock('$lib/stores/projects', () => ({
currentProjectId: writable<number>(5),
}));
// ...
})
However, now that we know these mocks get sent to the "top" of the file, this can lead to some head-scratching as we now have two conflicting mocks in the scope.
In practice, you only want to mock once and change the result of the mock.
Local variables won't work
The next logical thing to do is to create a local store that we can modify before our tests run...
const mockStore = writable<number>(1);
vi.mock('$lib/stores/projects', () => ({
currentProjectId: mockStore
}));
test('first test' => {
// ...
})
Now we're hit with the error we mentioned in the intro. As the mock is being hoisted and the mockStore
is not, when the mock runs for the first time mockStore
does not yet exist.
Fine, let's try moving the store to another file and import it.
import { mockStore } from './mocks'
vi.mock('$lib/stores/projects', () => ({
currentProjectId: mockStore
}));
test('first test' => {
// ...
})
Nope, same problem, mock is being called before the import.
vi.hosting
All is not lost though, vi.hoisting
saves the day by allowing us to wrap anything we may want to define locally in this function to hoist above the mocks.
For local variables, this is now a very simple fix to our code...
// Works! ✅
const mockStore = vi.hoisted(() => writable<number>(1));
vi.mock('$lib/stores/projects', () => ({
currentProjectId: mockStore
}));
test('first test' => {
// ...
})
test('second test' => {
// change mock
mockStore.set(5)
// ...
})
Awesome, but maybe you have a more complex mock and you prefer to have it in a separate file, how do we hoist the import above the mock?
Dynamic imports
You will used to be importing like this:
import { mockCurrentProjectStore } from './mockStores'
This is known as a static import, but there is another way known as, you probably guessed, a dynamic import.
const { mockCurrentProjectStore } = await import('./mockStores')
There are some small differences that are not relevant to our discussion here, check the MDN docs for more info, but for us, the key point is that we can pass this await import()
expression to vi.hoisted
to ensure that our import is available before our mock.
// Works! ✅
const { mockStore } = vi.hoisted(() => await import('./mockStores'))
vi.mock('$lib/stores/projects', () => ({
currentProjectId: mockStore
}));
test('first test' => {
// ...
})
Summing up
In my experience understanding what's happening behind the scenes with mock and hoisting makes testing much faster, enjoyable and intuitive. And remember, the above applies to any variables or functions you are using to mock.
Additional Reading
Official docs - https://vitest.dev/api/vi#vi-mock
How does vitest
do the hoisting? - https://github.com/vitest-dev/vitest/blob/main/packages/vitest/src/node/hoistMocks.ts
Dynamic imports - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import