skip to content

A Vue Test Utils test for a submit form passes locally but intermittently fails in CI, sometimes seeing 'Saving…' instead of 'Saved!'; which waiting mistakes cause that, and how do you make it deterministic?

level: seniorimportance: should knowfreq 40%

answer

  1. real time leaking into the test
  2. which async sources are uncontrolled
  3. one macrotask is not a timer
  4. fake clock, then let Vue flush

basics

~20 s

The test is racing real time: an unawaited wrapper call, a mock or component timer that uses real delays, an arbitrary sleep, or an unmocked request. Control every async source, flush it explicitly, and assert final states after flushPromises.

solid answer

~40 s

Intermittent failures mean something depends on **real elapsed time**, which CI machines have less of to spare. The usual causes: a wrapper call that is not awaited; a mock that resolves via `setTimeout`, which `flushPromises()` does not wait for because it only waits one macrotask; an arbitrary `await new Promise(r => setTimeout(r, 50))` sleep; a request that was never mocked; and asserting an intermediate state against an instantly resolving mock. The fix is to **own every async source**: mocks that resolve immediately or when the test says so, `await` on every wrapper method, `await flushPromises()` before final assertions, and **fake timers** for delays in the component, advancing the clock and then awaiting `nextTick()`, since Vue's update flush runs on promises, not timers.

code

ts · 18 lines
ts
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ContactForm from './ContactForm.vue'

afterEach(() => { vi.useRealTimers() })

test('hides the success message after three seconds', async () => {
  vi.useFakeTimers()
  const wrapper = mount(ContactForm, { props: { save: () => Promise.resolve() } })

  await wrapper.find('form').trigger('submit')
  await flushPromises()
  expect(wrapper.find('.success').exists()).toBe(true)

  vi.advanceTimersByTime(3000)
  await nextTick()
  expect(wrapper.find('.success').exists()).toBe(false)
})

go deeper

for a junior

Recall the three waits: await wrapper methods, await flushPromises for mocked requests, and use fake timers for delays in the component.

for a middle

Explain why each wait works: nextTick covers Vue's flush, flushPromises outlasts queued microtasks, fake timers turn real delays into explicit clock advances.

for a senior

Diagnose flakes by listing every async source in the test and making each one controlled, including leftovers from earlier tests such as mounted wrappers or unreleased fake timers.

for a principal

Set suite-wide rules, such as no sleeps, a floating-promise lint rule, API seams in components and automatic unmounting, so flakiness is prevented by design rather than retried away.

## Why intermittent means real time A test that passes on a fast laptop and fails sometimes in CI is almost never a Vue bug. It is a test whose outcome depends on **how much real time passes** between two points, which varies with machine load. In a Vue Test Utils test, time sneaks in through a handful of predictable doors. ## The usual culprits | Mistake | Why it is flaky | Deterministic replacement | |---|---|---| | `wrapper.find('form').trigger('submit')` without `await` | the assertion races Vue's DOM flush | `await` every `trigger`, `setValue`, `setProps` | | mock resolves via `setTimeout(r, 100)` | `flushPromises()` waits one macrotask, not 100 ms | resolve immediately, or with a controllable promise | | `await new Promise(r => setTimeout(r, 50))` as a wait | usually enough, sometimes not | wait for the specific source: `flushPromises()` or a fake clock | | request not mocked at all | depends on network latency, or fails | inject or mock the API | | asserting *Saving…* with an instantly resolving mock | the handler may finish within the awaited hops | keep the mock pending while asserting the in-flight state | | component uses `setTimeout`, such as hiding *Saved!* after 3 s | real timers fire on real time | fake timers, advance the clock | The *Saving…* versus *Saved!* symptom in the question points to the second, third or fifth row: the assertion either runs before the mock has settled, or relies on a precise interleaving of promise hops. ## Controlling promise-based work `flushPromises()` from `@vue/test-utils` returns a promise that resolves on the next macrotask, via `setImmediate` or `setTimeout(0)`. Everything already queued as a microtask, including resolved mocks, the handler's continuation and Vue's re-render, completes first. It is deterministic **only if every promise involved can settle without a timer**. So: - make mocks return resolved or rejected promises, or promises the test resolves by hand; - after the interaction, `await flushPromises()`, then assert the final DOM; - to assert an in-flight state, assert **before** resolving the controllable promise. ## Controlling timers If the component itself uses time, for example auto-hiding the success message after three seconds or debouncing an input, the test needs the runner's **fake timers**. The sequence is: 1. Install fake timers before the interaction that schedules the timer. 2. Perform the interaction and settle promises as usual. 3. Advance the fake clock past the delay; the timer callback runs and changes state. 4. `await nextTick()`, because the callback's state change still needs Vue's DOM flush. 5. Assert, then restore real timers so later tests are unaffected. Step 4 works under fake timers because Vue's update queue is flushed in a **promise microtask**, not a timer. `flushPromises()` also keeps working when fake timers are installed after `@vue/test-utils` is imported, because it captures `setImmediate` (or `setTimeout`) when the module first loads. Vue Test Utils also accounts for frozen clocks in `trigger`, stamping each event so that Vue's listeners do not discard it as older than the listener. ## Leftovers from earlier tests Some flakes are not in the failing test at all: - a wrapper from an earlier test is still mounted and its timer or promise fires during this test; - fake timers were installed by an earlier test and never restored; - a shared mock kept an implementation or call count from before. Unmount wrappers after each test, for example with `enableAutoUnmount(afterEach)` from `@vue/test-utils`, restore timers in `afterEach`, and reset mocks. ## Reading a flaky failure 1. Re-run the single test many times in a loop; if it flakes alone, the cause is inside the test. 2. If it only flakes in the full suite, look for leftovers from earlier tests. 3. List every async source the test touches, such as requests, timers and watchers, and mark which ones the test controls. 4. Replace each uncontrolled source with a controlled one before touching any timeout values. ## A checklist for review - every wrapper interaction is awaited, enforced by a floating-promise lint rule; - no sleeps: every wait names the async source it is for; - all network access is mocked through a seam the test controls; - delays in components are tested with fake timers and followed by `await nextTick()`; - in-flight states use pending promises; final states use `flushPromises()`. A test written this way does the same thing on every run, on every machine, because nothing in it depends on the clock on the wall.

  • Why is a 50 ms sleep in a test worse than a slightly slower but explicit wait?
    A sleep waits for a guess, not for the event. On a loaded CI machine the work may take longer and the test fails; on a fast machine it wastes time on every run. An explicit wait such as `flushPromises()` or advancing a fake clock ends exactly when the source has settled, so it is both faster and deterministic.
  • After advancing fake timers, why is `await nextTick()` still needed before asserting?
    Advancing the clock runs the timer callback, which changes reactive state synchronously. Vue then queues the DOM patch and flushes it in a promise microtask, which the fake clock does not run. Awaiting `nextTick()` lets that flush happen, so the assertion reads the patched DOM.

saying these in an interview costs you the question

  • Retrying the test in CI is a fine fix for occasional async failures.
  • flushPromises waits for every timer the component or a mock scheduled.
  • A short sleep is equivalent to flushPromises, just slower.
  • With fake timers installed, Vue's DOM updates stop until the clock advances.
  • If a test passes locally every time, its async handling is correct.