You click a button in a test, the component's state changes, and the assertion still sees the old text. The test is not wrong about what the component does. It is wrong about when. A Vue component does a lot of its work later: after the current line of code, after a promise settles, after a timer fires. A test that asserts too early sees the page as it was.
Each kind of "later" has its own way to wait, and picking the right one is the whole fix: nextTick for a render, flushPromises for a promise, a fake clock for a timer. The examples use Vitest 4.1 and Vue Test Utils 2.4 with environment: 'happy-dom' in the Vitest config, and the components live in src/components/.
Why the DOM lags behind your state
When reactive state changes, Vue does not re-render on the spot. It puts the component in a queue and renders everything in that queue once, in a microtask: a small job the JavaScript engine runs as soon as the current code finishes, before anything else happens. That batching is why ten changes in a row cost one render. It is also why this test fails:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>Count: {{ count }}</p>
<button type="button" @click="count++">Add one</button>
</template>
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import Counter from './Counter.vue'
it('adds one', () => {
const wrapper = mount(Counter)
wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('Count: 1')
})
Vitest reports expected 'Count: 0Add one' to contain 'Count: 1'. The click ran and count is 1, but the render is still queued. Vue's nextTick() returns a promise that resolves after the queued render, and Vue Test Utils' trigger, setValue and setProps return that promise for you. So the fix is one word:
it('adds one', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('Count: 1')
})
When you change state without going through the wrapper, for example by writing to a Pinia store or to a ref returned by a composable, nothing hands you a promise. Write await nextTick() yourself, imported from vue.
flushPromises after a fetch resolves
nextTick only waits for Vue's render queue. It knows nothing about your own promises. This component loads a name when it mounts:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const name = ref<string>()
onMounted(async () => {
const response = await fetch('/api/user')
const user: { name: string } = await response.json()
name.value = user.name
})
</script>
<template>
<p v-if="name">Hello, {{ name }}</p>
<p v-else>Loading…</p>
</template>
With fetch replaced by a mock that resolves at once, await nextTick() is still not enough. Two promises have to settle (the response, then its JSON) before name changes, and only then is a render queued. nextTick resolved long before that.
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, expect, it, vi } from 'vitest'
import UserName from './UserName.vue'
afterEach(() => {
vi.unstubAllGlobals()
})
it('greets the user once the request resolves', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: async () => ({ name: 'Ada' }) }))
const wrapper = mount(UserName)
await flushPromises()
expect(wrapper.text()).toContain('Hello, Ada')
})
flushPromises() waits for one macrotask, the next turn of the event loop. By then every promise callback that was already queued has run, including the ones those callbacks queued, and so has Vue's render. What it cannot do is wait for a real timer or a real network request. Those are not in the queue yet.
Fake timers for debounce and setTimeout
A search box that waits 300 ms after the last keystroke should not make your test wait 300 ms. Fake timers replace setTimeout and its relatives with a clock you move by hand. In Vitest 4.1, vi.useFakeTimers() fakes every timer API it finds (setTimeout, setInterval, setImmediate, Date and the rest) but leaves Node's process.nextTick and queueMicrotask alone. Promises are not timers, so they still resolve on their own, and Vue's nextTick keeps working because it is built on a promise.
<script setup lang="ts">
import { ref, watch } from 'vue'
const emit = defineEmits<{ search: [query: string] }>()
const query = ref('')
let timer: ReturnType<typeof setTimeout> | undefined
watch(query, (value) => {
clearTimeout(timer)
timer = setTimeout(() => emit('search', value.trim()), 300)
})
</script>
<template>
<label>Search <input v-model="query" type="search"></label>
</template>
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import SearchBox from './SearchBox.vue'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('emits once, 300 ms after the last keystroke', async () => {
const wrapper = mount(SearchBox)
const input = wrapper.find('input')
await input.setValue('v')
await input.setValue('vue')
vi.advanceTimersByTime(299)
expect(wrapper.emitted('search')).toBeUndefined()
vi.advanceTimersByTime(1)
expect(wrapper.emitted('search')).toEqual([['vue']])
})
The two advanceTimersByTime calls move the clock 299 ms, one short of the delay, and then the last millisecond, so the test checks both the wait and the emit.
Drop the await before setValue and the test fails with expected undefined to deeply equal [ [ 'vue' ] ]. A watch callback runs in Vue's queue too, so without the await the timer has not been started when you move the clock, and there is nothing to fire.
Always restore real timers in afterEach. A test that leaves fake timers on hands the frozen clock to every test that runs after it in the same file.
Timers and promises together need the Async methods
The hard cases mix both. This component retries once, a second after a failed request:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const props = defineProps<{ fetchUser: () => Promise<{ name: string }> }>()
const name = ref<string>()
onMounted(async () => {
try {
name.value = (await props.fetchUser()).name
}
catch {
await new Promise(resolve => setTimeout(resolve, 1000))
name.value = (await props.fetchUser()).name
}
})
</script>
<template>
<p>{{ name ?? 'Loading…' }}</p>
</template>
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import RetryingUser from './RetryingUser.vue'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('shows the user after one retry', async () => {
const fetchUser = vi.fn<() => Promise<{ name: string }>>()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce({ name: 'Ada' })
const wrapper = mount(RetryingUser, { props: { fetchUser } })
vi.advanceTimersByTime(1000)
await flushPromises()
expect(wrapper.text()).toBe('Ada')
})
It fails with expected 'Loading…' to be 'Ada'. advanceTimersByTime runs synchronously, straight after mount, before the rejected promise has been handled. At that moment the one-second timer does not exist, so nothing fires.
The async variant lets pending promises run before and between timers, so it also fires a timer that is only set once a promise settles (the Vitest docs call these "asynchronously set timers"). In the test, one awaited call replaces advanceTimersByTime and flushPromises:
const wrapper = mount(RetryingUser, { props: { fetchUser } })
await vi.advanceTimersByTimeAsync(1000)
expect(wrapper.text()).toBe('Ada')
Use the Async versions (advanceTimersByTimeAsync, runAllTimersAsync, advanceTimersToNextTimerAsync) whenever promises and timers meet.
The other mistake when the two meet is a home-made pause inside the test, such as await new Promise(r => setTimeout(r, 1100)). With fake timers on, that setTimeout is fake too. Nothing advances it, so the test hangs until Vitest's timeout.
flushPromises waits on a timer as well, but it does not hang. Vue Test Utils keeps a reference to setImmediate from the moment it loads, and the test file imports it before vi.useFakeTimers() runs, so that reference is the real one.
vi.waitFor when you cannot count the ticks
Sometimes you do not control how many steps sit between the action and the result, for example inside a third-party library. vi.waitFor runs a callback until it stops throwing, checking every 50 ms for up to one second by default. In the UserName test above, with the same fetch stub, it would replace flushPromises like this:
const wrapper = mount(UserName)
await vi.waitFor(() => expect(wrapper.text()).toContain('Hello, Ada'))
It is honest about uncertainty, but slower and vaguer than an exact await, so keep it for those cases.
Be careful combining it with fake timers. The docs say it then advances the fake clock by one interval on each check, so the defaults cover only about a second of fake time. The retry test above, which needs exactly a second, times out that way. Move the clock explicitly instead.
Suspense and async setup
A component with a top-level await in <script setup> has an async setup, and Vue only renders it inside <Suspense>:
<script setup lang="ts">
const props = defineProps<{ load: () => Promise<{ name: string }> }>()
const user = await props.load()
</script>
<template>
<h2>{{ user.name }}</h2>
</template>
Mount it directly and the wrapper stays empty while Vue warns: setup function returned a promise, but no <Suspense> boundary was found in the parent component tree. Give it the boundary it would have in your app, then flush:
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { defineComponent, h, Suspense } from 'vue'
import UserCard from './UserCard.vue'
it('renders the loaded user', async () => {
const load = vi.fn().mockResolvedValue({ name: 'Ada' })
const wrapper = mount(defineComponent({
render: () => h(Suspense, null, { default: () => h(UserCard, { load }) }),
}))
await flushPromises()
expect(wrapper.text()).toContain('Ada')
})
The render function avoids a string template, which would need Vue's runtime compiler in the test build. To reach the inner component, use wrapper.findComponent(UserCard). In a Nuxt project, mountSuspended from @nuxt/test-utils does this wrapping for you.
Match the failure to the missing wait
The failure message usually tells you which kind of "later" you missed:
- Old text right after a click or an input. An
awaitis missing beforetriggerorsetValue, or state changed outside the wrapper and needsawait nextTick(). - A loading state that never goes away with a mocked request. The promise has not settled yet. Add
await flushPromises(). - A debounced or delayed effect that never happens. Turn on fake timers, and check that the action which starts the timer was awaited before you move the clock.
- A timer and a promise in the same flow. Switch to
advanceTimersByTimeAsync. - An empty wrapper and a Suspense warning. The component has an async setup. Mount it inside
<Suspense>. - A test that times out after exactly five seconds with fake timers on. Five seconds is Vitest's default test timeout, and something is waiting on a fake timer that nothing advances, often a home-made
setTimeoutpause in the test.