Pulled ALL skills from 15 source repositories: - anthropics/skills: 16 (docs, design, MCP, testing) - obra/superpowers: 14 (TDD, debugging, agents, planning) - coreyhaines31/marketingskills: 25 (marketing, CRO, SEO, growth) - better-auth/skills: 5 (auth patterns) - vercel-labs/agent-skills: 5 (React, design, Vercel) - antfu/skills: 16 (Vue, Vite, Vitest, pnpm, Turborepo) - Plus 13 individual skills from various repos Mosaic Stack is not limited to coding — the Orchestrator and subagents serve coding, business, design, marketing, writing, logistics, analysis, and more. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
176 lines
5.1 KiB
Markdown
176 lines
5.1 KiB
Markdown
---
|
|
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)
|