963 markdown files reformatted with the repository's pinned prettier so pnpm format:check covers the folded tree like every other repo file. The formatter's embedded-language pass also normalized code fences (TS semicolons, closed HTML tags in examples, lowercased CSS hex colors, one renumbered list that skipped an index). Alphanumeric token deltas vs the fold commit were audited file-by-file; all are formatter-equivalent markup normalizations plus the four sanitized skills.
6.3 KiB
6.3 KiB
title, impact, impactDescription, type, tags
| title | impact | impactDescription | type | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Wrap Async Setup Components in Suspense for Testing | HIGH | Components with async setup() fail to render in tests without Suspense wrapper, causing cryptic errors | gotcha |
|
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
awaitin<script setup>orasync 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/vuewith caution (has Suspense issues)
Incorrect:
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:
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:
// 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),
};
}
// 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
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
// 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
// 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
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');
});