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');
});