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>
230 lines
6.3 KiB
Markdown
230 lines
6.3 KiB
Markdown
---
|
|
title: Wrap Async Setup Components in Suspense for Testing
|
|
impact: HIGH
|
|
impactDescription: Components with async setup() fail to render in tests without Suspense wrapper, causing cryptic errors
|
|
type: gotcha
|
|
tags: [vue3, testing, suspense, async-setup, vue-test-utils, vitest]
|
|
---
|
|
|
|
# Wrap Async Setup Components in Suspense for Testing
|
|
|
|
**Impact: HIGH** - Components using `async setup()` require a `<Suspense>` wrapper to function correctly. Testing them without Suspense causes the component to never render, leading to test failures and confusing errors.
|
|
|
|
Create a test wrapper component with Suspense or use a `mountSuspense` helper function for testing async components.
|
|
|
|
## Task Checklist
|
|
|
|
- [ ] Identify components with async setup (uses `await` in `<script setup>` or `async setup()`)
|
|
- [ ] Create a wrapper component with `<Suspense>` for testing
|
|
- [ ] Use `flushPromises()` after mounting to wait for async resolution
|
|
- [ ] Access the actual component via `findComponent()` for assertions
|
|
- [ ] Consider using `@testing-library/vue` with caution (has Suspense issues)
|
|
|
|
**Incorrect:**
|
|
```javascript
|
|
import { mount } from '@vue/test-utils'
|
|
import AsyncUserProfile from './AsyncUserProfile.vue'
|
|
|
|
// BAD: Async component without Suspense wrapper
|
|
test('displays user data', async () => {
|
|
// This won't render - Vue expects Suspense wrapper for async setup
|
|
const wrapper = mount(AsyncUserProfile, {
|
|
props: { userId: 1 }
|
|
})
|
|
|
|
await flushPromises()
|
|
|
|
// This fails - component never rendered
|
|
expect(wrapper.find('.username').text()).toBe('John')
|
|
})
|
|
```
|
|
|
|
**Correct - Manual Wrapper Component:**
|
|
```javascript
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { defineComponent, Suspense } from 'vue'
|
|
import AsyncUserProfile from './AsyncUserProfile.vue'
|
|
|
|
test('displays user data', async () => {
|
|
// Create wrapper component with Suspense
|
|
const TestWrapper = defineComponent({
|
|
components: { AsyncUserProfile },
|
|
template: `
|
|
<Suspense>
|
|
<AsyncUserProfile :user-id="1" />
|
|
<template #fallback>Loading...</template>
|
|
</Suspense>
|
|
`
|
|
})
|
|
|
|
const wrapper = mount(TestWrapper)
|
|
|
|
// Initially shows fallback
|
|
expect(wrapper.text()).toContain('Loading...')
|
|
|
|
// Wait for async setup to complete
|
|
await flushPromises()
|
|
|
|
// Find the actual component for detailed assertions
|
|
const profile = wrapper.findComponent(AsyncUserProfile)
|
|
expect(profile.find('.username').text()).toBe('John')
|
|
})
|
|
```
|
|
|
|
**Correct - Reusable Helper Function:**
|
|
```javascript
|
|
// test-utils.js
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { defineComponent, Suspense, h } from 'vue'
|
|
|
|
export async function mountSuspense(component, options = {}) {
|
|
const { props, slots, ...mountOptions } = options
|
|
|
|
const wrapper = mount(
|
|
defineComponent({
|
|
render() {
|
|
return h(
|
|
Suspense,
|
|
null,
|
|
{
|
|
default: () => h(component, props, slots),
|
|
fallback: () => h('div', 'Loading...')
|
|
}
|
|
)
|
|
}
|
|
}),
|
|
mountOptions
|
|
)
|
|
|
|
// Wait for async component to resolve
|
|
await flushPromises()
|
|
|
|
return {
|
|
wrapper,
|
|
// Provide easy access to the actual component
|
|
component: wrapper.findComponent(component)
|
|
}
|
|
}
|
|
```
|
|
|
|
```javascript
|
|
// AsyncUserProfile.test.js
|
|
import { mountSuspense } from './test-utils'
|
|
import AsyncUserProfile from './AsyncUserProfile.vue'
|
|
|
|
test('displays user data', async () => {
|
|
const { component } = await mountSuspense(AsyncUserProfile, {
|
|
props: { userId: 1 },
|
|
global: {
|
|
stubs: {
|
|
// Stub any child components if needed
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(component.find('.username').text()).toBe('John')
|
|
})
|
|
|
|
test('handles errors gracefully', async () => {
|
|
const { component } = await mountSuspense(AsyncUserProfile, {
|
|
props: { userId: 'invalid' }
|
|
})
|
|
|
|
expect(component.find('.error').exists()).toBe(true)
|
|
})
|
|
```
|
|
|
|
## Testing with onErrorCaptured
|
|
|
|
```javascript
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { defineComponent, Suspense, h, ref, onErrorCaptured } from 'vue'
|
|
import AsyncComponent from './AsyncComponent.vue'
|
|
|
|
test('catches async errors', async () => {
|
|
const capturedError = ref(null)
|
|
|
|
const TestWrapper = defineComponent({
|
|
setup() {
|
|
onErrorCaptured((error) => {
|
|
capturedError.value = error
|
|
return true // Prevent error propagation
|
|
})
|
|
return { capturedError }
|
|
},
|
|
render() {
|
|
return h(Suspense, null, {
|
|
default: () => h(AsyncComponent, { shouldFail: true }),
|
|
fallback: () => h('div', 'Loading...')
|
|
})
|
|
}
|
|
})
|
|
|
|
const wrapper = mount(TestWrapper)
|
|
await flushPromises()
|
|
|
|
expect(capturedError.value).toBeTruthy()
|
|
expect(capturedError.value.message).toContain('Failed to load')
|
|
})
|
|
```
|
|
|
|
## Using with Nuxt's mountSuspended
|
|
|
|
```javascript
|
|
// If using Nuxt, use the built-in mountSuspended helper
|
|
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
|
import AsyncPage from './AsyncPage.vue'
|
|
|
|
test('renders async page', async () => {
|
|
const wrapper = await mountSuspended(AsyncPage, {
|
|
props: { id: 1 }
|
|
})
|
|
|
|
expect(wrapper.find('h1').text()).toBe('Page Title')
|
|
})
|
|
```
|
|
|
|
## Important Caveats
|
|
|
|
### @testing-library/vue Limitation
|
|
```javascript
|
|
// CAUTION: @testing-library/vue has issues with Suspense
|
|
// Use @vue/test-utils for async components instead
|
|
|
|
// If you must use Testing Library, create manual wrapper:
|
|
import { render, waitFor } from '@testing-library/vue'
|
|
|
|
test('async component with testing library', async () => {
|
|
const TestWrapper = {
|
|
template: `
|
|
<Suspense>
|
|
<AsyncComponent />
|
|
</Suspense>
|
|
`,
|
|
components: { AsyncComponent }
|
|
}
|
|
|
|
const { getByText } = render(TestWrapper)
|
|
|
|
await waitFor(() => {
|
|
expect(getByText('Loaded content')).toBeInTheDocument()
|
|
})
|
|
})
|
|
```
|
|
|
|
### Accessing Component Instance
|
|
```javascript
|
|
test('access vm on async component', async () => {
|
|
const { wrapper, component } = await mountSuspense(AsyncComponent)
|
|
|
|
// The wrapper.vm is the Suspense wrapper - not useful
|
|
// Use component.vm for the actual async component
|
|
expect(component.vm.someData).toBe('value')
|
|
})
|
|
```
|
|
|
|
## Reference
|
|
- [Vue Test Utils - Async Suspense](https://test-utils.vuejs.org/guide/advanced/async-suspense)
|
|
- [Vue.js Suspense Documentation](https://vuejs.org/guide/built-ins/suspense.html)
|
|
- [Testing Library Vue Suspense Issue](https://github.com/testing-library/vue-testing-library/issues/230)
|