Testing async code and mocks
Run a composable inside withSetup(), replace the network with vi.mock() or MSW, wait with flushPromises(), and move time on with fake timers.
What you'll learn
When you finish, you can do these.- 1Run a composable in a test with withSetup()
- 2Replace the network with vi.mock() or MSW
- 3Wait with flushPromises() and move time with fake timers
- 4Fix a stuck fake clock and a mock that reads too early
Picture a people search on a team page. It lists everyone when the page opens, then filters the list 300 ms after you stop typing. Its code is async: the answer comes some time after the line that asks for it. Testing it brings two problems: a test has no server to ask, and the answer arrives after the line that checks it.
The Code tab holds the search, useUserSearch(), written as a composable: a reusable function whose name starts with use. Its three tests need no server and no real wait, and by the end you can write your own. You need one idea from Testing props, emits and slots: Vue updates the page a moment after a prop changes, so a test awaits setProps() before it reads the page.
Four steps for every async test
Every test of async code has the same shape: replace the network, run the code, wait until it settles, then check the result.
- Mock the networkvi.mock or MSW
- Run itwithSetup or mount
- WaitflushPromises or the fake clock
- Assert
The code under test is useUserSearch(), in src/composables/useUserSearch.ts. Its run() function gets the people from searchUsers() in src/api/users.ts, which asks the server with fetch. These lines decide when run() is called:
onMounted(() => run('')) // everyone, when the page opens
watch(query, (q) => {
clearTimeout(timer) // a new keystroke cancels the last search
timer = setTimeout(() => run(q), wait)
})
onMounted(), a lifecycle hook, runs once the component that uses the search is on the page: a search with no text loads everyone. watch() runs each time query, the search text, changes: clearTimeout() cancels the last timer and setTimeout() starts a new one for wait, 300 ms by default, so run() waits for 300 ms with no new key. A second hook, onUnmounted(), cancels the timer when the component leaves.
Call useUserSearch() straight from a test and its hooks have no component to belong to. onMounted() never runs, so the list stays empty, and Vue warns onMounted is called when there is no active component instance to be associated with.
The withSetup() helper in src/test/withSetup.ts, a pattern from the Vue docs, mounts a tiny app that calls your composable inside setup(), the function behind <script setup>:
const app = createApp({
setup() {
result = composable()
return () => null
},
})
app.mount(document.createElement('div'))
withSetup() returns the composable's result, such as users, and the app. The test calls app.unmount() at the end, which also runs onUnmounted().
Replace the network
The first step swaps out the code that talks to the server. vi, the helper object tests import from vitest, has vi.mock() for this: it replaces a whole module, a file the code imports, for every test in the file. Its second argument, the factory, returns the fake.
In the test file, useUserSearch.spec.ts, the fake searchUsers is a spy, a function made with vi.fn() that records its calls. Instead of calling fetch, it filters a fixed list of two people, Asha and Ben:
const { people } = vi.hoisted(() => ({
people: [{ id: 1, name: 'Asha' }, { id: 2, name: 'Ben' }],
}))
vi.mock('../../api/users', () => ({
searchUsers: vi.fn(async (q: string) =>
people.filter((p) => p.name.toLowerCase().includes(q))),
}))
Asked for as, the spy answers at once with Asha alone. Vitest hoists every vi.mock() call above the imports, so the mock is in place before useUserSearch imports src/api/users.ts. That is why people lives inside vi.hoisted(), which is hoisted too.
The error test makes the spy fail once with vi.mocked(searchUsers).mockRejectedValueOnce(new Error('Search failed with 500')), then expects the composable's error to hold that message. vi.mocked() only tells TypeScript that searchUsers is a spy.
vi.mock() replaces your own code, so the res.ok check in searchUsers, which throws on a failed answer, never runs.
Mock Service Worker, MSW, works one level lower: your code calls the real fetch, and MSW answers from a handler, a function for one address. The handler in src/mocks/handlers.ts filters the same two people by the q in the address:
http.get('/api/users', ({ request }) => {
const q = new URL(request.url).searchParams.get('q') ?? ''
return HttpResponse.json(people.filter((p) => p.name.toLowerCase().includes(q)))
}),
A request for /api/users?q=as gets Asha alone.
vitest.setup.ts, a file Vitest runs before each test file, starts the MSW server with onUnhandledFrame: 'error': a request no handler matches fails with [MSW] Error: intercepted a request without a matching request handler, so a forgotten mock never reaches a real server.
One test can override a handler with server.use(), and server.resetHandlers() drops it after the test.
Wait for promises, and for time
The third step waits, for a promise or for a timer. flushPromises() from Vue Test Utils waits for one turn of the event loop, JavaScript's cycle of queued work. That is enough for promises that are already settled, such as the spy's answer and the code that runs after it, so the mount test, the first in the file, needs nothing more:
const [{ users }, app] = withSetup(() => useUserSearch())
await flushPromises()
expect(users.value).toEqual(people)
users holds Asha and Ben. A timer would still be waiting after that one turn.
For timers, vi.useFakeTimers() swaps setTimeout for a fake clock that moves only when the test moves it. The timer test settles the load on mount on the real clock first, then fakes the clock:
it('searches 300 ms after the last change', async () => {
const [{ query, users }, app] = withSetup(() => useUserSearch())
await flushPromises() // the load on mount, on the real clock
vi.useFakeTimers()
query.value = 'as'
await vi.advanceTimersByTimeAsync(299)
expect(searchUsers).toHaveBeenCalledTimes(1) // only the load on mount
await vi.advanceTimersByTimeAsync(1)
expect(users.value).toEqual([people[0]])
app.unmount()
})
advanceTimersByTimeAsync(299) moves the clock 299 ms, runs the timers that fell due and lets their promises settle. One millisecond short, searchUsers has only the call from the mount. One more, and users holds Asha alone.
vi.runAllTimersAsync() runs every pending timer. vi.useRealTimers(), in afterEach(), turns the real clock back on after every test.
What goes wrong
Two mistakes often break async tests. The first waits on the wrong clock: fake timers on, then flushPromises() to wait for the search.
vi.useFakeTimers()
query.value = 'as'
await flushPromises()
expect(users.value).toEqual([people[0]])
The test fails with AssertionError: expected [ { id: 1, name: 'Asha' }, …(1) ] to deeply equal [ { id: 1, name: 'Asha' } ]. flushPromises() ticks on the real clock, the fake one never moves, and the 300 ms timer never fires, so the list still holds everyone. Move the fake clock instead:
vi.useFakeTimers()
query.value = 'as'
await vi.advanceTimersByTimeAsync(299)
expect(searchUsers).toHaveBeenCalledTimes(1) // only the load on mount
await vi.advanceTimersByTimeAsync(1)
expect(users.value).toEqual([people[0]])
The timer fires at 300 ms, and the test passes.
The second mistake is a vi.mock() factory that reads a constant declared in the file.
const people = [{ id: 1, name: 'Asha' }, { id: 2, name: 'Ben' }]
vi.mock('../../api/users', () => ({
searchUsers: vi.fn().mockResolvedValue(people),
}))
Hoisting moves vi.mock() above the const, so the file fails before any test with [vitest] There was an error when mocking a module and Caused by: ReferenceError: Cannot access 'people' before initialization. Declare the value inside vi.hoisted():
const { people } = vi.hoisted(() => ({
people: [{ id: 1, name: 'Asha' }, { id: 2, name: 'Ben' }],
}))
people then exists before the factory runs, and the tests load.
What to remember
- Run a composable that uses lifecycle hooks inside
withSetup(), and unmount the app at the end. vi.mock()is hoisted above the imports; values its factory needs go invi.hoisted().- MSW answers real
fetchcalls from handlers, so your request code runs too. Start it in a setup file withonUnhandledFrame: 'error'. flushPromises()waits one turn, not for every promise and not for timers.- With fake timers, move the clock with
advanceTimersByTimeAsync()orrunAllTimersAsync(), and switch back withvi.useRealTimers().
In your project
Do these in a real project. Each one says how to confirm it worked.- Add a test that, like the timer test, settles the mount load and fakes the clock, then sets
query.value = 'a', advances 200 ms, sets'as'and advances 300 ms. ExpectsearchUsersto have been called 2 times, the last time with'as': it passes. Comment outclearTimeout(timer)in the watcher and it fails withexpected "vi.fn()" to be called 2 times, but got 3 times. - In
searches 300 ms after the last change, replace the secondadvanceTimersByTimeAsyncwithawait flushPromises(). The test fails with the unfiltered list in the diff. Change it toawait vi.runAllTimersAsync()and it passes. - Copy the spec to
useUserSearch.msw.spec.ts. Delete thevi.hoisted()andvi.mock()blocks and the timer test, and declarepeopleas a plain constant: the mount test passes, answered bysrc/mocks/handlers.ts. In the error test, replace themockRejectedValueOnceline withserver.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 }))), importingserverfromsrc/mocks/node.tsandhttpandHttpResponsefrommsw/http. It passes, and the message now comes from the realres.okcheck.