skip to content

With Vue Test Utils, why is awaiting `trigger('submit')` not enough to see a form's success message after an API call, and what does `flushPromises()` add?

level: middleimportance: must knowfreq 55%

answer

  1. two different kinds of pending work
  2. Vue's queue versus the request's promise
  3. a macrotask outlasts queued microtasks
  4. timer-based delays are not flushed

basics

~20 s

Awaiting trigger only waits for Vue's pending DOM update, not for the request promise the submit handler awaits. flushPromises() resolves on the next macrotask, so already-settled mock promises, their continuations and Vue's re-render all run first.

solid answer

~40 s

The submit handler sets a `saving` state, then `await`s the API call, and only after that sets `saved`. `await trigger('submit')` returns Vue's `nextTick()`, which waits for the update **Vue already queued**; it knows nothing about the API promise. After it, Vue's already-queued update has flushed, but nothing guarantees the API continuation has run, so *Saved!* may or may not be there. `flushPromises()` from `@vue/test-utils` returns a promise that resolves on the next **macrotask** (`setImmediate`, or `setTimeout(0)` where that is missing). Every microtask queued before then runs first: the mock's resolution, the handler's continuation that sets `saved`, and Vue's resulting DOM flush. The limit: it only settles promises that are already resolvable. A mock that resolves after a real timer, or a request that was never mocked, is not flushed.

code

vue · 22 lines
vue
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{ save: (data: { name: string }) => Promise<void> }>()
const name = ref('')
const status = ref<'idle' | 'saving' | 'saved'>('idle')

async function onSubmit() {
  status.value = 'saving'
  await props.save({ name: name.value })
  status.value = 'saved'
}
</script>

<template>
  <form @submit.prevent="onSubmit">
    <input v-model="name" />
    <button :disabled="status === 'saving'">Save</button>
    <p v-if="status === 'saving'">Saving…</p>
    <p v-if="status === 'saved'" class="success">Saved!</p>
  </form>
</template>

go deeper

for a junior

Recall that API calls need an extra wait: mock the call, then await flushPromises from Vue Test Utils before asserting on the result.

for a middle

Explain the two pending queues: nextTick covers Vue's queued update, flushPromises waits one macrotask so every settled promise and the resulting re-render complete first.

for a senior

Control async sources deliberately: pending mocks for in-flight states, flushPromises for final states, fake timers for delays, and never an unmocked request.

for a principal

Standardise how components receive their async dependencies, as props, injections or modules, so every test can control settlement without fragile microtask counting.

## The scenario A contact form calls a `save` function when submitted. While the request is in flight it shows *Saving…* and disables the button; when the request resolves it shows *Saved!*. The test mocks `save`, submits the form and expects the success message. With only `await trigger('submit')`, the test is unreliable or simply fails. ## Two different kinds of pending work After the submit event, two unrelated things are pending: 1. **Vue's update queue.** The handler set `status.value = 'saving'`, so Vue queued a re-render. 2. **The request's promise.** The handler is suspended at `await props.save(...)`, and the rest of it, which sets `saved`, will run only when that promise settles. `trigger` returns `nextTick()`. That promise tracks item 1 only. Whether item 2 has also finished by the time the test resumes depends on how the mock resolves and on microtask ordering; for a pending request it definitely has not. ## What `flushPromises` does In Vue Test Utils 2.5.1, `flushPromises` is a few lines: ```ts const scheduler = typeof setImmediate === 'function' ? setImmediate : setTimeout export function flushPromises() { return new Promise(resolve => { scheduler(resolve, 0) }) } ``` It resolves on the next **macrotask**. JavaScript runs every queued **microtask**, which includes promise continuations, before it moves to the next macrotask. So by the time `await flushPromises()` returns: - a mock that returned an already-resolved promise has resolved; - the handler's code after `await` has run and set `status.value = 'saved'`; - Vue's re-render, itself a microtask, has patched the DOM. That is why the Vue Test Utils guide pairs it with mocked HTTP clients: the test waits for everything outstanding without knowing how many promise hops the component makes. ## Asserting the in-flight state reliably To assert *Saving…*, the mock must still be **pending** when the test looks. With an instantly resolving mock, the handler may finish during the extra promise hops before the test resumes, so the intermediate state can be skipped. A controllable promise removes the guesswork: ```ts let finish!: () => void const save = () => new Promise<void>(r => { finish = r }) const wrapper = mount(ContactForm, { props: { save } }) await wrapper.find('form').trigger('submit') expect(wrapper.text()).toContain('Saving') finish() await flushPromises() expect(wrapper.find('.success').exists()).toBe(true) ``` ## Choosing the right wait | Pending work | Wait with | Why | |---|---|---| | a re-render after a wrapper interaction | `await trigger(...)` / `await setValue(...)` | the method returns `nextTick()` | | a re-render after changing state directly | `await nextTick()` from `vue` | no wrapper method was involved | | mocked promises that are already resolvable | `await flushPromises()` | a macrotask outlasts all queued microtasks | | a timer in the component or in a mock | fake timers, advance the clock, then `await nextTick()` | timers are not microtasks | ## Why not just await `nextTick()` twice? Awaiting `nextTick()` twice only buys a small, fixed number of extra promise hops, which happens to be enough for some components and not for others. Every extra `await` inside the handler, a mock that wraps its result in another promise, or a library that adds a `.then` shifts the count. A test that counts ticks encodes the component's internal promise structure and breaks when that structure changes. `flushPromises()` expresses the real intent, *wait until nothing promise-based is left*, and stays correct when the number of hops changes. ## Limits worth stating in an interview - `flushPromises` does not make a **real** request finish. If the API is not mocked, the promise stays pending and the assertion fails, or the test talks to the network. - A mock written as `new Promise(r => setTimeout(r, 100))` resolves on a timer, after the flush. - A long chain that schedules **new macrotasks** at each step needs more than one flush; that is a sign the test should control the async source instead. - `flushPromises` waits; it does not assert. If an error is thrown in the handler after the `await`, the test needs the component to surface it, such as an error message, to see it. ## The shape of a good test 1. Mock the API so the test decides when and how it settles. 2. Interact through wrapper methods and `await` them. 3. Settle the mock, `await flushPromises()`, then assert on the final DOM. 4. For the in-flight state, assert while the mock is still pending.

  • The mock returns `Promise.resolve()`, and a test asserts *Saving…* right after `await trigger('submit')`. Why can that fail?
    The handler's continuation after an already-resolved promise is just another microtask. Returning `nextTick()` from the async `trigger` and awaiting it costs several promise hops, during which the continuation can run, set `saved` and trigger a second flush. The in-flight state is skipped. Keep the mock pending while asserting it, then resolve it.
  • Why does `flushPromises` use `setImmediate` or `setTimeout` rather than `Promise.resolve()`?
    A resolved promise only schedules one more microtask, which runs ahead of continuations queued later in the chain. A macrotask callback runs only after the whole microtask queue is empty, so waiting for one guarantees every promise hop that is already possible has completed, however many there are.

saying these in an interview costs you the question

  • Awaiting trigger waits for everything the handler started, including API calls.
  • flushPromises forces real network requests to complete immediately.
  • flushPromises also runs setTimeout callbacks the component scheduled.
  • Calling nextTick twice in a row is equivalent to flushPromises.
  • With an instantly resolving mock, the loading state is always visible after await trigger.