Files
2025-11-29 17:52:04 +08:00

13 KiB

Testing Vue Composables in Isolation

Table of Contents

Overview

Test composables based on whether they're independent (reactivity only) or dependent (lifecycle/inject).

Composable Categories

Independent Composables 🔓

Definition: Use ONLY Vue's Reactivity APIs (ref, computed, watch). No lifecycle hooks, no provide/inject.

Testing: Test directly like regular functions.

import { ref, computed, type Ref, type ComputedRef } from 'vue'

// ✅ Independent: Only uses reactivity
function useSum(a: Ref<number>, b: Ref<number>): ComputedRef<number> {
  return computed(() => a.value + b.value)
}

// Test directly
describe('useSum', () => {
  it('correctly computes the sum of two numbers', () => {
    const num1 = ref(2)
    const num2 = ref(3)
    const sum = useSum(num1, num2)

    expect(sum.value).toBe(5)
  })
})

Dependent Composables 🔗

Definition: Use lifecycle hooks (onMounted, onUnmounted) or provide/inject. Require component context.

Testing: Need helper to simulate component mounting.

import { ref, watch, onMounted } from 'vue'

// ❌ Dependent: Uses onMounted lifecycle
function useLocalStorage<T>(key: string, initialValue: T) {
  const value = ref<T>(initialValue)

  function loadFromLocalStorage() {
    const storedValue = localStorage.getItem(key)
    if (storedValue !== null) {
      value.value = JSON.parse(storedValue)
    }
  }

  onMounted(loadFromLocalStorage)  // Requires component context

  watch(value, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  })

  return { value }
}

Testing Independent Composables

Test directly without helpers:

import { describe, it, expect } from 'vitest'
import { ref } from 'vue'

describe('useCounter (independent)', () => {
  it('increments count', () => {
    const count = ref(0)
    const increment = () => count.value++

    expect(count.value).toBe(0)
    increment()
    expect(count.value).toBe(1)
  })
})

Testing Dependent Composables

The withSetup Helper

For composables using lifecycle hooks:

import type { App } from 'vue'
import { createApp } from 'vue'

export function withSetup<T>(composable: () => T): [T, App] {
  let result: T

  const app = createApp({
    setup() {
      result = composable()
      return () => {}
    }
  })

  app.mount(document.createElement('div'))

  return [result, app]
}

Usage:

import { describe, it, expect, beforeEach, afterEach } from 'vitest'

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear()
  })

  it('should load initial value', () => {
    const [result, app] = withSetup(() =>
      useLocalStorage('testKey', 'initValue')
    )

    expect(result.value.value).toBe('initValue')
    app.unmount()
  })

  it('should load from localStorage if exists', () => {
    localStorage.setItem('testKey', JSON.stringify('fromStorage'))

    const [result, app] = withSetup(() =>
      useLocalStorage('testKey', 'initialValue')
    )

    expect(result.value.value).toBe('fromStorage')
    app.unmount()
  })

  it('should sync changes to localStorage', async () => {
    const [result, app] = withSetup(() =>
      useLocalStorage('testKey', 'initial')
    )

    result.value.value = 'updated'

    // Wait for watch to trigger
    await new Promise(resolve => setTimeout(resolve, 0))

    expect(localStorage.getItem('testKey')).toBe(JSON.stringify('updated'))
    app.unmount()
  })
})

Why withSetup works:

  1. Creates actual Vue app
  2. Executes composable during setup phase
  3. Triggers lifecycle hooks (onMounted, etc.)
  4. Returns result + app for cleanup

Testing Composables with Inject

The Problem

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')
  }

  const getUpperCase = () => message.toUpperCase()
  const getReversed = () => message.split('').reverse().join('')

  return { message, getUpperCase, getReversed }
}

This composable REQUIRES a provider. Testing directly will fail.

The useInjectedSetup Helper

import type { InjectionKey } from 'vue'
import { createApp, defineComponent, h, provide } from 'vue'

type InstanceType<V> = V extends { new (...arg: any[]): infer X } ? X : never
type VM<V> = InstanceType<V> & { unmount: () => void }

interface InjectionConfig {
  key: InjectionKey<any> | string
  value: any
}

export function useInjectedSetup<TResult>(
  setup: () => TResult,
  injections: 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 mounted = mount(Provider)

  return {
    ...result,
    unmount: mounted.unmount
  } as TResult & { unmount: () => void }
}

function mount<V>(Comp: V) {
  const el = document.createElement('div')
  const app = createApp(Comp as any)
  const unmount = () => app.unmount()
  const comp = app.mount(el) as any as VM<V>
  comp.unmount = unmount
  return comp
}

Testing with useInjectedSetup

import { describe, it, expect } from 'vitest'
import { useInjectedSetup } from './helpers'
import { MessageKey, useMessage } from './useMessage'

describe('useMessage', () => {
  it('should handle injected message', () => {
    const wrapper = useInjectedSetup(
      () => useMessage(),
      [{ key: MessageKey, value: 'hello world' }]
    )

    expect(wrapper.message).toBe('hello world')
    expect(wrapper.getUpperCase()).toBe('HELLO WORLD')
    expect(wrapper.getReversed()).toBe('dlrow olleh')

    wrapper.unmount()
  })

  it('should throw error when message not provided', () => {
    expect(() => {
      useInjectedSetup(() => useMessage(), [])
    }).toThrow('Message must be provided')
  })
})

How it works:

  1. Creates Provider component that provides injections
  2. Creates child component that uses the composable
  3. Mounts both in proper hierarchy
  4. Returns composable result + unmount function

Testing Patterns

Pattern 1: Independent Composable with Reactive State

// Composable
function useCounter(initial = 0) {
  const count = ref(initial)
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = initial

  return { count, increment, decrement, reset }
}

// Test
describe('useCounter', () => {
  it('initializes with default value', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })

  it('initializes with custom value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

  it('increments count', () => {
    const { count, increment } = useCounter()
    increment()
    expect(count.value).toBe(1)
  })

  it('resets to initial value', () => {
    const { count, increment, reset } = useCounter(5)
    increment()
    increment()
    expect(count.value).toBe(7)
    reset()
    expect(count.value).toBe(5)
  })
})

Pattern 2: Composable with Lifecycle (withSetup)

// Composable
function useDocumentTitle(title: Ref<string>) {
  onMounted(() => {
    document.title = title.value
  })

  watch(title, (newTitle) => {
    document.title = newTitle
  })
}

// Test
describe('useDocumentTitle', () => {
  it('sets document title on mount', () => {
    const title = ref('Test Title')
    const [_, app] = withSetup(() => useDocumentTitle(title))

    expect(document.title).toBe('Test Title')
    app.unmount()
  })

  it('updates document title when ref changes', async () => {
    const title = ref('Initial')
    const [_, app] = withSetup(() => useDocumentTitle(title))

    title.value = 'Updated'
    await nextTick()

    expect(document.title).toBe('Updated')
    app.unmount()
  })
})

Pattern 3: Async Composable with Lifecycle

// Composable
function useAsyncData<T>(fetcher: () => Promise<T>) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const isLoading = ref(false)

  const execute = async () => {
    isLoading.value = true
    error.value = null

    try {
      data.value = await fetcher()
    } catch (e) {
      error.value = e as Error
    } finally {
      isLoading.value = false
    }
  }

  onMounted(() => {
    execute()
  })

  return { data, error, isLoading, execute }
}

// Test
describe('useAsyncData', () => {
  it('loads data on mount', async () => {
    const fetcher = vi.fn().mockResolvedValue({ id: 1, name: 'Test' })

    const [result, app] = withSetup(() => useAsyncData(fetcher))

    expect(result.isLoading.value).toBe(true)

    await vi.waitFor(() => {
      expect(result.isLoading.value).toBe(false)
    })

    expect(result.data.value).toEqual({ id: 1, name: 'Test' })
    expect(result.error.value).toBeNull()

    app.unmount()
  })

  it('handles errors', async () => {
    const fetcher = vi.fn().mockRejectedValue(new Error('Failed'))

    const [result, app] = withSetup(() => useAsyncData(fetcher))

    await vi.waitFor(() => {
      expect(result.isLoading.value).toBe(false)
    })

    expect(result.error.value).toBeInstanceOf(Error)
    expect(result.error.value?.message).toBe('Failed')
    expect(result.data.value).toBeNull()

    app.unmount()
  })
})

Pattern 4: Composable Using Other Composables

// Composables
function useAuth() {
  const token = ref<string | null>(null)
  const isAuthenticated = computed(() => token.value !== null)
  return { token, isAuthenticated }
}

function useAuthenticatedUser(userId: string) {
  const { token, isAuthenticated } = useAuth()
  const user = ref<User | null>(null)

  watchEffect(async () => {
    if (isAuthenticated.value && token.value) {
      user.value = await fetchUser(userId, token.value)
    }
  })

  return { user, isAuthenticated }
}

// Test
describe('useAuthenticatedUser', () => {
  it('only fetches when authenticated', async () => {
    const fetchUser = vi.fn()

    const [result, app] = withSetup(() => {
      // Mock useAuth
      const auth = useAuth()
      auth.token.value = null  // Not authenticated

      return useAuthenticatedUser('123')
    })

    expect(fetchUser).not.toHaveBeenCalled()

    app.unmount()
  })
})

Common Testing Mistakes

Mistake Fix
Testing dependent composable directly Use withSetup for lifecycle composables
Not unmounting after tests Always call app.unmount()
Missing inject providers Use useInjectedSetup with injections array
Not waiting for async operations Use vi.waitFor() or await nextTick()
Testing implementation details Test behavior, not internal refs
Forgetting to clear mocks/storage Use beforeEach/afterEach hooks

Testing Checklist

For Independent Composables

  • Test directly without helpers
  • Test all return values
  • Test edge cases (null, undefined, empty)
  • Test reactivity (refs update correctly)

For Dependent Composables (Lifecycle)

  • Use withSetup helper
  • Test onMounted behavior
  • Test watch/watchEffect triggers
  • Unmount app after each test
  • Clear side effects (localStorage, etc.)

For Dependent Composables (Inject)

  • Use useInjectedSetup helper
  • Test with provided values
  • Test error when injection missing
  • Unmount wrapper after each test

For Async Composables

  • Test loading states
  • Test success states
  • Test error states
  • Use vi.waitFor() for assertions
  • Mock async dependencies

Quick Reference

// Independent: Test directly
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)

// Lifecycle: Use withSetup
const [result, app] = withSetup(() => useLocalStorage('key', 'value'))
expect(result.value.value).toBe('value')
app.unmount()

// Inject: Use useInjectedSetup
const wrapper = useInjectedSetup(
  () => useMessage(),
  [{ key: MessageKey, value: 'hello' }]
)
expect(wrapper.message).toBe('hello')
wrapper.unmount()

Summary

Composable Type Testing Approach Helper Needed
Independent (ref, computed, watch) Test directly No
Dependent (lifecycle hooks) withSetup Yes
Dependent (inject) useInjectedSetup Yes

Golden Rule: If it uses lifecycle or inject, it needs a helper. Otherwise, test it like a regular function.