Faking fetch, a module or the clock takes a few lines in Vitest. Removing the fake afterwards is the part that goes wrong: one that outlives its test causes the most confusing failure in a suite, a test that passes alone and fails when the whole file runs. You will test a component that fetches data without a server, replace an API module and test a debounce without waiting for it, then make the config remove mocks and stubs before each test.
The examples use Vitest 3.2 with Vue Test Utils 2.4 and the jsdom environment, in a plain Vite project with src/ at the root. The full vitest.config.ts is in the last section.
vi.fn and vi.spyOn: the two kinds of fake
vi.fn() creates a new function that records every call. You can give it an implementation, or tell it what to return with mockReturnValue and mockResolvedValue, and then assert on it with toHaveBeenCalledWith. Use it when you are handing a function to the code under test: a callback, a prop, a dependency.
vi.spyOn(object, 'method') wraps a method that already exists. By default the real method still runs and the spy only records calls; chain .mockReturnValue() to replace the behaviour. Use it when the code under test reaches for something you did not pass in, such as localStorage.getItem or console.warn.
import { expect, it, vi } from 'vitest'
it('records calls', () => {
const onSave = vi.fn(() => true)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
onSave('draft')
console.warn('unused prop')
expect(onSave).toHaveBeenCalledWith('draft')
expect(warn).toHaveBeenCalledTimes(1)
})
Both return the same kind of object, a mock, with the same methods. The difference is only where the function came from, and that decides how it is cleaned up, which we come back to at the end.
Stubbing fetch for a component that loads data
Here is a component that loads a user when it mounts.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const props = defineProps<{ id: number }>()
const name = ref('')
const error = ref('')
onMounted(async () => {
const response = await fetch(`/api/users/${props.id}`)
if (!response.ok) {
error.value = 'Could not load user'
return
}
const user = await response.json()
name.value = user.name
})
</script>
<template>
<p v-if="error">{{ error }}</p>
<p v-else>{{ name || 'Loading' }}</p>
</template>
fetch is a global, so the cleanest way to replace it is vi.stubGlobal('fetch', mock). Vitest remembers the original and vi.unstubAllGlobals() puts it back. The mock resolves to the smallest object the component needs: ok and a json() method.
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, expect, it, vi } from 'vitest'
import UserCard from '../UserCard.vue'
const fetchMock = vi.fn()
afterEach(() => {
vi.unstubAllGlobals()
})
it('shows the user name', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ name: 'Ada' }) } as Response)
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(UserCard, { props: { id: 1 } })
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith('/api/users/1')
expect(wrapper.text()).toBe('Ada')
})
it('shows an error when the request fails', async () => {
fetchMock.mockResolvedValue({ ok: false } as Response)
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(UserCard, { props: { id: 2 } })
await flushPromises()
expect(wrapper.text()).toBe('Could not load user')
})
flushPromises() from Vue Test Utils is the line that makes this work. mount returns while onMounted is still waiting for fetch, so without it the first assertion would see Loading. flushPromises waits until every pending promise callback has run, including the two awaits in the component and the DOM update that follows, and then your assertions see the finished state. Reach for it after any action that triggers async work; await nextTick() only waits for one render, not for a promise chain.
Replacing a module with vi.mock
Stubbing fetch works, but as the app grows the component usually calls a function in an API module instead. Then the thing to fake is the module. Suppose the request moves to src/api/users.ts:
export async function fetchUser(id: number): Promise<{ name: string }> {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
return response.json()
}
UserCard.vue now calls fetchUser and catches its error. Only the script changes; the template stays as it was:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchUser } from '../api/users'
const props = defineProps<{ id: number }>()
const name = ref('')
const error = ref('')
onMounted(async () => {
try {
const user = await fetchUser(props.id)
name.value = user.name
} catch {
error.value = 'Could not load user'
}
})
</script>
vi.mock(path, factory) replaces that module for the whole test file: the test's own import and the import inside UserCard.vue both get the fake. Write the path as the test file would import it. The factory returns what the fake module exports. vi.mocked() is a typing helper that tells TypeScript the import is now a mock, so mockResolvedValue is allowed on it. The test file now mocks the module instead of stubbing fetch:
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { fetchUser } from '../../api/users'
import UserCard from '../UserCard.vue'
vi.mock('../../api/users', () => ({
fetchUser: vi.fn(),
}))
it('shows the user name', async () => {
vi.mocked(fetchUser).mockResolvedValue({ name: 'Ada' })
const wrapper = mount(UserCard, { props: { id: 1 } })
await flushPromises()
expect(fetchUser).toHaveBeenCalledWith(1)
expect(wrapper.text()).toBe('Ada')
})
One rule about vi.mock catches people out: it is hoisted. Vitest moves the call to the top of the file, above the imports, so that the fake is in place before UserCard.vue imports the module. That means the factory cannot use a variable you declared above it: at run time that line has not executed yet, and the file fails with [vitest] There was an error when mocking a module, caused by ReferenceError: Cannot access '…' before initialization. If you need the mock in a variable, create it with const mocks = vi.hoisted(() => ({ fetchUser: vi.fn() })), which is hoisted too, and return it from the factory: vi.mock('../../api/users', () => mocks).
To keep most of a module and replace one export, use the importOriginal function the factory receives: vi.mock('../../api/users', async (importOriginal) => ({ ...(await importOriginal()), fetchUser: vi.fn() })).
Prefer the module mock to the fetch stub once the request has a named function. The test then says what it depends on (fetchUser returned Ada) rather than how it is implemented (a URL and a JSON body), and a change from fetch to a client library does not touch the test.
Fake timers for a debounce
A debounce waits until the user has stopped typing before it acts. A test should not wait too. Here is a small composable that calls search 300 milliseconds after the last change to query:
import { ref, watch } from 'vue'
export function useDebouncedSearch(search: (query: string) => void, delay = 300) {
const query = ref('')
let timer: ReturnType<typeof setTimeout> | undefined
watch(query, value => {
clearTimeout(timer)
timer = setTimeout(() => search(value), delay)
})
return { query }
}
vi.useFakeTimers() replaces setTimeout and friends with a clock you control. vi.advanceTimersByTime(300) moves that clock forward and runs every timer that falls due, synchronously, so the assertion on the next line sees the result.
import { nextTick } from 'vue'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useDebouncedSearch } from '../useDebouncedSearch'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('calls search once, after the delay, with the last value', async () => {
const search = vi.fn()
const { query } = useDebouncedSearch(search)
query.value = 'vu'
await nextTick()
query.value = 'vue'
await nextTick()
expect(search).not.toHaveBeenCalled()
vi.advanceTimersByTime(299)
expect(search).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(search).toHaveBeenCalledTimes(1)
expect(search).toHaveBeenCalledWith('vue')
})
The await nextTick() lines are there because a watcher does not run the moment you assign; Vue queues it and runs it in a microtask. Microtasks are not timers, so fake timers do not touch them, and nextTick still works.
Since Vitest 3, useFakeTimers() fakes every timer API it finds, including performance.now() and requestAnimationFrame, except process.nextTick and queueMicrotask. If something in the code under test reads performance.now(), it now reads a frozen clock until you advance it.
The test that passes alone and fails in the suite
Every fake above outlives the test that created it unless something removes it. Vitest isolates test files from each other, but not the tests inside a file, so whatever one test leaves behind is still there when the next one runs. The result is a test that passes when you run it alone, with it.only or with npx vitest -t and part of its name, and fails when the whole file runs.
The module mock above already sets this trap. Its factory runs once, the first time the module is imported, so every test in UserCard.spec.ts shares the same fetchUser mock. Add a second test that counts the calls:
it('asks for the user once', async () => {
vi.mocked(fetchUser).mockResolvedValue({ name: 'Grace' })
mount(UserCard, { props: { id: 2 } })
await flushPromises()
expect(fetchUser).toHaveBeenCalledTimes(1)
})
On its own, this test passes. In the full file it fails: toHaveBeenCalledTimes(1) finds two calls, because the call made in 'shows the user name' is still in the mock's history. Nothing in the failing test is wrong, so the error points away from the cause.
Other fakes leak the same way: a fetch stub that is never removed, or a mockResolvedValue set in test three that test seven silently relies on. Fake timers leak too. A test that calls vi.useFakeTimers() and never switches back leaves a frozen clock behind, and a later test that waits for a setTimeout to fire fails with Error: Test timed out in 5000ms, although nothing in it mentions timers.
Cleaning up mocks and stubs in the config
The fix is to put the cleanup where it cannot be forgotten: in the config, once.
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
restoreMocks: true,
unstubGlobals: true,
unstubEnvs: true,
},
})
restoreMocks: true calls mockRestore() on every mock before each test: call history is cleared, a vi.fn(impl) goes back to impl, and a vi.spyOn puts the original method back on its object. With it, fetchUser starts every test with no calls, so the test above passes. unstubGlobals undoes every vi.stubGlobal (so the afterEach in the fetch test can go), and unstubEnvs undoes vi.stubEnv. No config option switches fake timers off, so keep the afterEach(() => vi.useRealTimers()) next to every useFakeTimers().
The three related options differ in how far they go:
clearMocksonly forgets calls.mockResetforgets calls and resets each implementation to the one it was created with.restoreMocksdoes both and also puts spied-on methods back on their objects.
Use restoreMocks and set what each test needs inside that test; a test that depends on a value set in a different test is already broken, it just has not failed yet. To find the ones that are, run the suite in a random order once: npx vitest --run --sequence.shuffle.