Private side effects
Loading "Private Side Effects (π solution)"
Run locally for transcripts
As a rule of thumb, you should always strive toward mocking at the lowest possible level. Module mocking, however, lies on the higher spectrum of things. But that's not a bad thing in itself. In fact, if module mocking is justified, it gives you full control over that module's values and behaviors.
I am going to utilize that power 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 allows proper type inference from the mocked module as well as makes the mock proof to renaming or deleting the module.
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 am wrapping 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 could 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 information and implementation are reset between tests:
afterEach(() => {
vi.resetAllMocks()
})
By mocking the entire
@workshop/epic-sdk
module, my test becomes 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 mock functions you've practiced earlier. Using the
.mockResolvedValue
and .mockRejectedValue()
built-in methods in Vitest, I will model different behavior scenarios of 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 write 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 test, 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, which ...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')
})