--- 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)