Private side effects
Loading "Private Side Effects (🏁 solution)"
Run locally for transcripts
As a rule of thumb, you should always aim to mock at the lowest possible level. Module mocking, however, lies on the higher spectrum of things. But that's not necessarily a bad thing. In fact, if module mocking is justified, it gives you full control over that module's values and behaviors.
I am going to use the power of module mocking to both exclude the telemetry side effect and mock the implementation of the
queryTable
function exported from @workshop/epic-sdk
.In the
beforeAll()
hook, I will use vi.mock()
function to mock a module.vi.mock(import('@workshop/epic-sdk'), async () => {
return {
queryTable: queryTableMock,
}
})
The
vi.mock()
function accepts two arguments:- The module import to mock (
import('@workshop/epic-sdk')
).; - An optional factory function to implement the module.
Using a dynamicimport()
here enables proper type inference from the mocked module and makes the mock resilient to module renaming or deletion.
I will also provide the factory function to control the exact exports returned from the mocked module. In this case, I will swap the actual
queryTable
function with the queryTableMock
introduced earlier:import { authorize, User } from './authorize.js'
const queryTableMock = vi.hoisted(() => vi.fn<() => Promise<User>>())
vi.mock(import('@workshop/epic-sdk'), async () => {
return {
queryTable: queryTableMock,
}
})
🦉 Since
vi.mock()
function is hoisted (gets evaluated as the first thing no matter where it's defined), it won't be able to reference any values in the test's scope, like the queryTableMock
function. To solve this, I wrap the mock function in the vi.hoisted()
utility:const queryTableMock = vi.hoisted(() => vi.fn<() => Promise<User>>())
This will expose the
queryTableMock
function to the vi.mock()
factory correctly so it can use it as the value of the queryTable
export. Nice!And since I am using a mock function, to begin with, I need to make sure its call state and implementation are reset between tests:
afterEach(() => {
vi.resetAllMocks()
})
By mocking the entire
@workshop/epic-sdk
module, my test is in full control over its exports, but also over what gets evaluated at its root level (which, in this case, is nothing). This gives me two for the price of one: I am mocking the behavior I need (queryTable
) while also excluding the side effects I don't (telemetry).From this point on, the rest of the test is not much different from using the mock functions you've practiced earlier. Using Vitest's
.mockResolvedValue()
and .mockRejectedValue()
methods, I can simulate different scenarios for the queryTable
function to test my authorize()
function:test('returns the authorized user', async () => {
queryTableMock.mockResolvedValue({
id: 'abc-123',
name: 'Kody Koala',
})
await expect(authorize('abc-123')).resolves.toEqual({
id: 'abc-123',
name: 'Kody Koala',
})
})
test('returns null if no user was found', async () => {
queryTableMock.mockResolvedValue(null)
await expect(authorize('abc-123')).resolves.toBeNull()
})
test('throws an error if querying the user failed', async () => {
queryTableMock.mockRejectedValue(new Error('Original error'))
await expect(authorize('abc-123')).rejects.toThrow(
'Failed to fetch user by id "abc-123"',
)
})
vi.mock()
vs vi.doMock()
Vitest ships two main APIs to mock modules:
They may look similar on the surface, but the way they behave is drastically different.
vi.mock()
vi.mock()
function is hoisted. It means that no matter where you call it in your test file, it will always be evaluated as the first thing in that test module. This makes it useful for mocking module dependencies of the code you import in tests, since imports are also hoisted, and would otherwise be subjected to import order issues:import { something } from 'dependency'
import { behavior } './tested-code'
vi.mock(import('dependency'))
Despite thevi.mock()
call happening after the import todependency
(visually), it actually appears before it (hoisted) so the mock could take effect.
This affects any dependencies
vi.mock()
itself has as well. For example, if you wish to reference a variable in the vi.mock()
factory function, you have to wrap that variable in vi.hoisted()
, otherwise it will not be defined by the moment your factory runs.vi.doMock()
vi.doMock()
is the unhoisted alternative to vi.mock()
. Unlike its sibling, vi.doMock()
calls are not hoisted:import { something } from 'dependency'
// `something` has the original value here.
vi.doMock(import('dependency'))
import { something } from 'dependency'
// `something` has the mocked value from now on.
This gives you more control over the import order in exchange for requiring you to import your tested code dynamically to circumvent the standard imports hoisting and have the module mock take effect:
beforeAll(() => {
vi.doMock(import('dependency'))
})
test('validates the behavior', () => {
// The "./tested-code" MUST be imported dynamically in the test's scope
// because otherwise it will be hoisted to the top of the file,
// evaluating BEFORE `vi.doMock()` takes effect.
const { behavior } = await import('./tested-code')
})