vue-composable-testing
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
More from alexanderop/workouttracker
vue-composables
Write high-quality Vue 3 composables following established patterns and best practices. Use when creating new composables, refactoring existing ones, or reviewing composable code. Triggers include requests to "create a composable", "write a use* function", "extract logic into a composable", or any Vue Composition API reusable logic task.
23product-planning
|
19vitest-mocking
|
15add-exercises
Add new exercises to the workout tracker database. Use when asked to add exercises, expand the exercise library, or check what exercises exist. Triggers include "add exercise", "new exercise", "exercise database", "what exercises", "missing exercises", "expand exercises".
12systematic-debugging
|
12repository-pattern
Create and manage Dexie/IndexedDB repositories with type-safe interfaces, converters, and standardized CRUD operations. Use when (1) adding entity storage, (2) implementing save/load/delete operations, (3) designing database schema and indexes, (4) converting between database (Db*) and domain types, (5) handling database errors or migrations, (6) using existing repositories (SettingsRepository, WorkoutsRepository, TemplatesRepository, CustomExercisesRepository, BenchmarksRepository, ActiveWorkoutRepository). Triggers include "database", "repository", "save data", "fetch from database", "delete from storage", "database schema", "database table", "indexes", "migration", "persist", "convert workout", "converter", "buildPartialUpdate", "mock repository", "database error", "bulk operations", "import/export", or specific repository names.
12