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.1 KiB
6.1 KiB
title, impact, impactDescription, type, tags
| title | impact | impactDescription | type | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Test Complex Composables with Host Component Wrapper | MEDIUM | Composables using lifecycle hooks or provide/inject fail when tested directly without a component context | capability |
|
Test Complex Composables with Host Component Wrapper
Impact: MEDIUM - Composables that use Vue lifecycle hooks (onMounted, onUnmounted) or dependency injection (inject) require a component context to function. Testing them directly will cause errors or incorrect behavior.
Simple composables using only reactivity APIs can be tested directly. Complex composables need a helper function that creates a host component context.
Task Checklist
- Identify if composable uses lifecycle hooks or inject
- For simple composables (refs, computed only): test directly
- For complex composables: use
withSetuphelper pattern - Clean up by unmounting the test app after each test
- Use
app.provide()to mock injected dependencies
Simple Composable - Test Directly:
// composables/useCounter.js
import { ref, computed } from 'vue';
export function useCounter(initialValue = 0) {
const count = ref(initialValue);
const doubled = computed(() => count.value * 2);
const increment = () => count.value++;
return { count, doubled, increment };
}
// useCounter.test.js
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
// CORRECT: Simple composable can be tested directly
describe('useCounter', () => {
it('initializes with default value', () => {
const { count } = useCounter();
expect(count.value).toBe(0);
});
it('increments count', () => {
const { count, increment } = useCounter();
increment();
expect(count.value).toBe(1);
});
it('computes doubled value', () => {
const { count, doubled, increment } = useCounter(5);
expect(doubled.value).toBe(10);
increment();
expect(doubled.value).toBe(12);
});
});
Complex Composable - Use Host Wrapper:
// composables/useFetch.js
import { ref, onMounted, onUnmounted, inject } from 'vue';
export function useFetch(url) {
const data = ref(null);
const error = ref(null);
const loading = ref(true);
let controller = null;
// Uses inject - needs component context
const apiClient = inject('apiClient');
// Uses lifecycle hooks - needs component context
onMounted(async () => {
controller = new AbortController();
try {
const response = await apiClient.get(url, { signal: controller.signal });
data.value = response.data;
} catch (e) {
if (e.name !== 'AbortError') error.value = e;
} finally {
loading.value = false;
}
});
onUnmounted(() => {
controller?.abort();
});
return { data, error, loading };
}
// test-utils.js
import { createApp } from 'vue';
/**
* Helper to test composables that need component context
*/
export function withSetup(composable) {
let result;
const app = createApp({
setup() {
result = composable();
// Return a render function to suppress warnings
return () => {};
},
});
app.mount(document.createElement('div'));
return [result, app];
}
// useFetch.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushPromises } from '@vue/test-utils';
import { withSetup } from './test-utils';
import { useFetch } from './useFetch';
describe('useFetch', () => {
let app;
const mockApiClient = {
get: vi.fn(),
};
afterEach(() => {
// IMPORTANT: Clean up to trigger onUnmounted
app?.unmount();
});
it('fetches data on mount', async () => {
mockApiClient.get.mockResolvedValue({ data: { id: 1, name: 'Test' } });
const [result, testApp] = withSetup(() => useFetch('/api/test'));
app = testApp;
// Provide mocked dependency
app.provide('apiClient', mockApiClient);
// Wait for async operations
await flushPromises();
expect(result.data.value).toEqual({ id: 1, name: 'Test' });
expect(result.loading.value).toBe(false);
expect(result.error.value).toBeNull();
});
it('handles errors', async () => {
const testError = new Error('Network error');
mockApiClient.get.mockRejectedValue(testError);
const [result, testApp] = withSetup(() => useFetch('/api/test'));
app = testApp;
app.provide('apiClient', mockApiClient);
await flushPromises();
expect(result.error.value).toBe(testError);
expect(result.data.value).toBeNull();
});
});
Enhanced withSetup Helper with Provide Support
// test-utils.js
export function withSetup(composable, options = {}) {
let result;
const app = createApp({
setup() {
result = composable();
return () => {};
},
});
// Apply global provides before mounting
if (options.provide) {
Object.entries(options.provide).forEach(([key, value]) => {
app.provide(key, value);
});
}
app.mount(document.createElement('div'));
return [result, app];
}
// Usage
const [result, app] = withSetup(() => useMyComposable(), {
provide: {
apiClient: mockApiClient,
currentUser: { id: 1, name: 'Test User' },
},
});
Testing with @vue/test-utils mount
import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { useFetch } from './useFetch';
test('useFetch in component context', async () => {
const TestComponent = defineComponent({
setup() {
const { data, loading } = useFetch('/api/users');
return { data, loading };
},
template: '<div>{{ loading ? "Loading..." : data }}</div>',
});
const wrapper = mount(TestComponent, {
global: {
provide: {
apiClient: mockApiClient,
},
},
});
await flushPromises();
expect(wrapper.text()).toContain('Test data');
});