--- title: Properly Handle Async Updates with nextTick and flushPromises impact: HIGH impactDescription: Race conditions and flaky tests occur when async DOM updates or API calls complete after assertions run type: gotcha tags: [vue3, testing, async, flushPromises, nextTick, vitest, vue-test-utils, race-condition] --- # Properly Handle Async Updates with nextTick and flushPromises **Impact: HIGH** - Vue updates the DOM asynchronously. Without properly awaiting these updates, tests may assert against stale DOM state, causing intermittent failures and false negatives. Use `await` with triggers and `setValue`, use `nextTick` for reactive updates, and use `flushPromises` for external async operations like API calls. ## Task Checklist - [ ] Always await `trigger()` and `setValue()` calls - [ ] Use `await nextTick()` after programmatic reactive state changes - [ ] Use `await flushPromises()` for external async operations (API calls, timers) - [ ] Don't chain multiple `nextTick` calls - use `flushPromises` instead - [ ] Consider using `waitFor` from testing-library for polling assertions **Incorrect:** ```javascript import { mount } from '@vue/test-utils' import SearchComponent from './SearchComponent.vue' // BAD: Not awaiting trigger - assertion runs before DOM updates test('search filters results', () => { const wrapper = mount(SearchComponent) wrapper.find('input').setValue('vue') // Missing await! wrapper.find('button').trigger('click') // Missing await! // This assertion likely fails - DOM hasn't updated yet expect(wrapper.findAll('.result').length).toBe(3) }) // BAD: Using nextTick for API calls test('loads data from API', async () => { const wrapper = mount(DataLoader) await nextTick() // This won't wait for the API call! // Assertion runs before fetch completes expect(wrapper.find('.data').text()).toBe('Loaded data') }) ``` **Correct:** ```javascript import { mount, flushPromises } from '@vue/test-utils' import { nextTick } from 'vue' import SearchComponent from './SearchComponent.vue' import DataLoader from './DataLoader.vue' // CORRECT: Await trigger and setValue test('search filters results', async () => { const wrapper = mount(SearchComponent) await wrapper.find('input').setValue('vue') await wrapper.find('button').trigger('click') expect(wrapper.findAll('.result').length).toBe(3) }) // CORRECT: Use flushPromises for API calls test('loads data from API', async () => { const wrapper = mount(DataLoader) // Wait for all pending promises to resolve await flushPromises() expect(wrapper.find('.data').text()).toBe('Loaded data') }) ``` ## When to Use Each Method ### `await trigger()` / `await setValue()` - User Interactions ```javascript // These methods return nextTick internally await wrapper.find('button').trigger('click') await wrapper.find('input').setValue('new value') await wrapper.find('form').trigger('submit') ``` ### `await nextTick()` - Programmatic Reactive Updates ```javascript import { nextTick } from 'vue' test('reflects programmatic state changes', async () => { const wrapper = mount(Counter) // Direct state modification (when testing with exposed internals) wrapper.vm.count = 5 await nextTick() // Wait for Vue to update DOM expect(wrapper.find('.count').text()).toBe('5') }) ``` ### `await flushPromises()` - External Async Operations ```javascript import { flushPromises } from '@vue/test-utils' test('displays fetched data', async () => { const wrapper = mount(UserProfile, { props: { userId: 1 } }) // Wait for component's API call to complete await flushPromises() expect(wrapper.find('.username').text()).toBe('John') }) // Sometimes you need multiple flushPromises for chained async operations test('processes data after fetch', async () => { const wrapper = mount(DataProcessor) await flushPromises() // Wait for fetch await flushPromises() // Wait for processing triggered by fetch expect(wrapper.find('.processed').exists()).toBe(true) }) ``` ## Common Pattern: Combining Methods ```javascript test('submits form and shows success', async () => { const wrapper = mount(ContactForm) // Fill form (awaiting each interaction) await wrapper.find('#name').setValue('John') await wrapper.find('#email').setValue('john@example.com') // Submit form await wrapper.find('form').trigger('submit') // Wait for API submission to complete await flushPromises() // Assert success state expect(wrapper.find('.success-message').exists()).toBe(true) }) ``` ## Testing with MSW or Mock APIs ```javascript import { flushPromises } from '@vue/test-utils' import { rest } from 'msw' import { setupServer } from 'msw/node' const server = setupServer( rest.get('/api/user', (req, res, ctx) => { return res(ctx.json({ name: 'John' })) }) ) test('displays user data', async () => { const wrapper = mount(UserCard) // MSW might require multiple flushPromises await flushPromises() await flushPromises() expect(wrapper.find('.name').text()).toBe('John') }) ``` ## Reference - [Vue Test Utils - Asynchronous Behavior](https://test-utils.vuejs.org/guide/advanced/async-suspense) - [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)