vue-composable-testing
π―Skillfrom alexanderop/workouttracker
vue-composable-testing skill from alexanderop/workouttracker
Installation
npx skills add https://github.com/alexanderop/workouttracker --skill vue-composable-testingSkill Details
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).
Overview
# 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.
```ts
// useSum.ts - Independent composable
import type { ComputedRef, Ref } from 'vue'
import { computed } from 'vue'
export function useSum(a: Ref
return computed(() => a.value + b.value)
}
```
```ts
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.
```ts
// useLocalStorage.ts - Uses onMounted
import { onMounted, ref, watch } from 'vue'
export function useLocalStorage
const value = ref
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 }
}
```
```ts
// 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.
```ts
// useMessage.ts - Uses inject
import type { InjectionKey } from 'vue'
import { inject } from 'vue'
export const MessageKey: InjectionKey
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(''),
}
}
```
```ts
// 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:
```ts
import type { App } from 'vue'
import { createApp } from 'vue'
export function withSetup
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:
```ts
import type { InjectionKey } from 'vue'
import { createApp, defineComponent, h, provide } from 'vue'
interface InjectionConfig {
key: InjectionKey
value: unknown
}
export function useInjectedSetup
setup: () => TResult,
injections: ReadonlyArray
): 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
```ts
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
```ts
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
```ts
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 this repository7
Tracks and automatically advances kettlebell swing progressions through reps, time, and weight phases using an EMOM timer.
Helps product owners and business analysts streamline product planning by creating, refining, and prioritizing user stories, PRDs, backlogs, and roadmaps.
Generates high-quality Vue 3 composables by following established patterns and best practices for creating reusable Composition API logic.
Enables adding new exercises to the workout tracker database by specifying exercise details like name, equipment, muscle group, type, and tracking metrics.
Enables comprehensive Vue 3 integration testing using Vitest Browser Mode and Page Objects for verifying complex user flows and multi-component interactions.
Provides comprehensive Vitest mocking strategies for creating test doubles, replacing dependencies, and verifying interactions in JavaScript/TypeScript tests.
Enhance mobile app accessibility by generating clear, context-aware notifications with screen reader compatibility and adaptive formatting.