| name | vue-composable-testing |
| description | Test Vue 3 composables with Vitest following best practices. Use when writing tests for composables, creating test helpers for Vue reactivity, or when asked to "test a composable", "write tests for use*", or "help test Vue composition functions". Covers independent composables (direct testing) and dependent composables (lifecycle/inject testing with helpers). |
Vue Composable Testing Guide
Test composables based on their dependency type: Independent (direct testing) or Dependent (requires component context).
Quick Classification
| Type | Characteristics | Testing Approach |
|---|---|---|
| Independent | Uses only reactivity APIs (ref, computed, watch) | Test directly like functions |
| Dependent | Uses lifecycle hooks (onMounted) or inject | Use withSetup or useInjectedSetup helpers |
Testing Independent Composables
Independent composables use only Vue's reactivity system without lifecycle hooks or dependency injection.
// useSum.ts - Independent composable
import type { ComputedRef, Ref } from 'vue'
import { computed } from 'vue'
export function useSum(a: Ref<number>, b: Ref<number>): ComputedRef<number> {
return computed(() => a.value + b.value)
}
import { describe, expect, it } from 'vitest'
// useSum.spec.ts - Direct testing
import { ref } from 'vue'
import { useSum } from '../useSum'
describe('useSum', () => {
it('computes sum of two numbers', () => {
const num1 = ref(2)
const num2 = ref(3)
const sum = useSum(num1, num2)
expect(sum.value).toBe(5)
})
it('reacts to value changes', () => {
const num1 = ref(1)
const num2 = ref(1)
const sum = useSum(num1, num2)
num1.value = 10
expect(sum.value).toBe(11)
})
})
Testing Dependent Composables
With Lifecycle Hooks
Composables using onMounted, onUnmounted, etc. require a component context. Use the withSetup helper.
// useLocalStorage.ts - Uses onMounted
import { onMounted, ref, watch } from 'vue'
export function useLocalStorage<TValue>(key: string, initialValue: TValue) {
const value = ref<TValue>(initialValue)
onMounted(() => {
const stored = localStorage.getItem(key)
if (stored !== null) {
value.value = JSON.parse(stored)
}
})
watch(value, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
})
return { value }
}
// useLocalStorage.spec.ts - Using withSetup
import { beforeEach, describe, expect, it } from 'vitest'
import { useLocalStorage } from '../useLocalStorage'
import { withSetup } from './helpers/withSetup'
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear()
})
it('loads initial value', () => {
const [result] = withSetup(() => useLocalStorage('key', 'initial'))
expect(result.value.value).toBe('initial')
})
it('loads value from localStorage on mount', () => {
localStorage.setItem('key', JSON.stringify('stored'))
const [result] = withSetup(() => useLocalStorage('key', 'initial'))
expect(result.value.value).toBe('stored')
})
it('persists changes to localStorage', () => {
const [result] = withSetup(() => useLocalStorage('key', 'initial'))
result.value.value = 'updated'
expect(JSON.parse(localStorage.getItem('key')!)).toBe('updated')
})
})
With Inject
Composables using inject require provided values. Use the useInjectedSetup helper.
// useMessage.ts - Uses inject
import type { InjectionKey } from 'vue'
import { inject } from 'vue'
export const MessageKey: InjectionKey<string> = Symbol('message')
export function useMessage() {
const message = inject(MessageKey)
if (!message) {
throw new Error('Message must be provided')
}
return {
message,
getUpperCase: () => message.toUpperCase(),
getReversed: () => message.split('').reverse().join(''),
}
}
// useMessage.spec.ts - Using useInjectedSetup
import { describe, expect, it } from 'vitest'
import { MessageKey, useMessage } from '../useMessage'
import { useInjectedSetup } from './helpers/useInjectedSetup'
describe('useMessage', () => {
it('uses injected message', () => {
const result = useInjectedSetup(
() => useMessage(),
[{ key: MessageKey, value: 'hello world' }]
)
expect(result.message).toBe('hello world')
expect(result.getUpperCase()).toBe('HELLO WORLD')
expect(result.getReversed()).toBe('dlrow olleh')
result.unmount()
})
it('throws when message not provided', () => {
expect(() => {
useInjectedSetup(() => useMessage(), [])
}).toThrow('Message must be provided')
})
})
Helper Functions
Create these helpers in your test utilities. See references/test-helpers.md for complete implementations.
withSetup
Mounts a minimal Vue app to trigger lifecycle hooks:
import type { App } from 'vue'
import { createApp } from 'vue'
export function withSetup<TResult>(composable: () => TResult): [TResult, App] {
let result: TResult
const app = createApp({
setup() {
result = composable()
return () => {}
},
})
app.mount(document.createElement('div'))
return [result!, app]
}
useInjectedSetup
Creates a provider component for inject-dependent composables:
import type { InjectionKey } from 'vue'
import { createApp, defineComponent, h, provide } from 'vue'
interface InjectionConfig {
key: InjectionKey<unknown> | string
value: unknown
}
export function useInjectedSetup<TResult>(
setup: () => TResult,
injections: ReadonlyArray<InjectionConfig> = []
): TResult & { unmount: () => void } {
let result!: TResult
const Comp = defineComponent({
setup() {
result = setup()
return () => h('div')
},
})
const Provider = defineComponent({
setup() {
injections.forEach(({ key, value }) => {
provide(key, value)
})
return () => h(Comp)
},
})
const el = document.createElement('div')
const app = createApp(Provider)
app.mount(el)
return {
...result,
unmount: () => app.unmount(),
}
}
Testing Patterns
Async Composables
describe('useAsyncData', () => {
it('handles async operations', async () => {
const [result] = withSetup(() => useAsyncData())
expect(result.isLoading.value).toBe(true)
await result.fetch()
expect(result.isLoading.value).toBe(false)
expect(result.data.value).toBeDefined()
})
})
Error States
describe('useFetch', () => {
it('captures errors', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'))
const [result] = withSetup(() => useFetch('/api/data'))
await result.execute()
expect(result.error.value).toBeInstanceOf(Error)
expect(result.error.value?.message).toBe('Network error')
})
})
Cleanup
describe('useInterval', () => {
it('cleans up on unmount', () => {
const clearSpy = vi.spyOn(globalThis, 'clearInterval')
const [, app] = withSetup(() => useInterval(() => {}, 1000))
app.unmount()
expect(clearSpy).toHaveBeenCalled()
})
})
Best Practices
- Classify first - Determine if composable is independent or dependent before writing tests
- Always unmount - Call
app.unmount()orresult.unmount()to prevent memory leaks - Test reactivity - Verify computed values update when dependencies change
- Mock external APIs - Use
vi.spyOnorvi.mockfor fetch, localStorage, etc. - Test error paths - Verify composables expose errors correctly
- Clear state - Use
beforeEachto reset mocks and external state like localStorage