--- title: Test Complex Composables with Host Component Wrapper impact: MEDIUM impactDescription: Composables using lifecycle hooks or provide/inject fail when tested directly without a component context type: capability tags: [vue3, testing, composables, vitest, lifecycle-hooks, provide-inject] --- # 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 `withSetup` helper pattern - [ ] Clean up by unmounting the test app after each test - [ ] Use `app.provide()` to mock injected dependencies **Simple Composable - Test Directly:** ```javascript // 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 } } ``` ```javascript // 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:** ```javascript // 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 } } ``` ```javascript // 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] } ``` ```javascript // 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 ```javascript // 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 ```javascript 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: '