本文 AI 產出,尚未審核

Vue3 單元:組合式函式(Composables) – 測試 composable


簡介

在 Vue3 中,組合式 API 讓開發者可以把可重用的邏輯抽離成獨立的函式(亦稱 composable),從而提升程式碼的可維護性與可測試性。雖然 composable 的撰寫相對簡單,但如果缺乏系統化的測試,未來的功能調整或重構很容易引入隱蔽的 bug。

本篇文章將說明 如何為 composable 撰寫單元測試,涵蓋測試環境的建置、常見測試技巧、以及在實務專案中如何將測試流程落實。無論你是剛接觸 Vue3 的新手,或是已有一定開發經驗的中階工程師,都能從中獲得可直接套用的範例與最佳實踐。


核心概念

1. 為什麼要測試 composable?

優點 說明
獨立性 composable 本身不依賴 Vue 元件的模板,測試時可只聚焦於邏輯本身。
可重用性 多個元件共用同一個 composable,測試一次即可保證所有使用者的行為正確。
防止回歸 任何對 composable 的改動,都會即時在測試中捕捉到不預期的變化。
文件化 測試碼本身就是使用說明,閱讀測試可快速了解 composable 的 API 與預期行為。

2. 測試工具選型

工具 特色 常見搭配
Vitest 輕量、原生支援 ES 模組、與 Vite 無縫整合。 @vue/test-utilspinia
Jest 生態成熟、內建快照測試、豐富的 mock 功能。 vue-jestbabel-jest
Vue Test Utils 官方提供的 Vue 元件測試工具,亦可用於測試 composable(透過 shallowMountrender Vitest / Jest

本文示範以 Vitest 為例,因其設定簡潔且與 Vue3/Vite 項目高度相容。若你仍在使用 Jest,只要把 vitest 替換成 jest,概念與程式碼幾乎相同。

3. 測試 composable 的基本步驟

  1. 建立測試環境:安裝 vitest@vue/test-utils@vue/reactivity(若需要手動觸發回應式更新)。
  2. 匯入 composable:在測試檔中直接 import 目標函式。
  3. 呼叫 composable:通常在 setup 內執行,返回的 state、computed、method 都可以直接使用。
  4. 斷言結果:使用 expect 斷言值、函式呼叫次數、或是 side‑effect(例如 localStorage、API 呼叫)。
  5. 清理:在 afterEach 中重置 mock,以免影響其他測試。

程式碼範例

以下提供 五個實用範例,從最簡單的計數器到包含非同步 API 呼叫與外部依賴的複雜情境,逐步說明測試技巧。

範例 1:簡易計數器 composable

// src/composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)

  const inc = (step = 1) => {
    count.value += step
  }

  const dec = (step = 1) => {
    count.value -= step
  }

  return { count, inc, dec }
}

測試

// tests/unit/useCounter.spec.js
import { useCounter } from '../../src/composables/useCounter'
import { nextTick } from 'vue'

describe('useCounter', () => {
  it('初始化為傳入的數值', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

  it('inc() 會正確遞增', async () => {
    const { count, inc } = useCounter()
    inc()
    await nextTick()
    expect(count.value).toBe(1)

    inc(4)
    await nextTick()
    expect(count.value).toBe(5)
  })

  it('dec() 會正確遞減', async () => {
    const { count, dec } = useCounter(5)
    dec()
    await nextTick()
    expect(count.value).toBe(4)

    dec(2)
    await nextTick()
    expect(count.value).toBe(2)
  })
})

重點:即使 ref 是同步的,仍建議使用 await nextTick() 以保證 Vue 的回應式更新已完成,避免測試在非同步環境下產生假陰性。


範例 2:使用 localStorage 的持久化計數器

// src/composables/usePersistentCounter.js
import { ref, watch } from 'vue'

export function usePersistentCounter(key = 'counter', initial = 0) {
  const stored = localStorage.getItem(key)
  const count = ref(stored !== null ? Number(stored) : initial)

  // 每次 count 改變自動寫回 localStorage
  watch(count, (newVal) => {
    localStorage.setItem(key, String(newVal))
  })

  const inc = () => {
    count.value++
  }

  return { count, inc }
}

測試(使用 Vitest 的 mock)

// tests/unit/usePersistentCounter.spec.js
import { usePersistentCounter } from '../../src/composables/usePersistentCounter'
import { vi } from 'vitest'
import { nextTick } from 'vue'

describe('usePersistentCounter', () => {
  const STORAGE_KEY = 'my-counter'

  beforeEach(() => {
    // 清除原本的 mock
    vi.restoreAllMocks()
    // 建立 localStorage 的 mock
    const storageMock = (() => {
      let store = {}
      return {
        getItem: vi.fn((k) => store[k] ?? null),
        setItem: vi.fn((k, v) => { store[k] = v }),
        clear: vi.fn(() => { store = {} })
      }
    })()
    vi.stubGlobal('localStorage', storageMock)
  })

  it('從 localStorage 讀取初始值', () => {
    localStorage.setItem(STORAGE_KEY, '7')
    const { count } = usePersistentCounter(STORAGE_KEY)
    expect(count.value).toBe(7)
    expect(localStorage.getItem).toHaveBeenCalledWith(STORAGE_KEY)
  })

  it('inc() 會同步更新 localStorage', async () => {
    const { count, inc } = usePersistentCounter(STORAGE_KEY, 3)
    inc()
    await nextTick()
    expect(count.value).toBe(4)
    expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, '4')
  })
})

技巧vi.stubGlobal 可以把全域物件(如 localStorage)替換成 mock,避免測試在 Node 環境下因無法存取瀏覽器 API 而失敗。


範例 3:非同步 API 呼叫的資料抓取 composable

// src/composables/useFetchUser.js
import { ref } from 'vue'
import axios from 'axios'

export function useFetchUser(userId) {
  const user = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const fetch = async () => {
    loading.value = true
    error.value = null
    try {
      const { data } = await axios.get(`/api/users/${userId}`)
      user.value = data
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  // 直接在 composable 初始化時呼叫一次
  fetch()

  return { user, loading, error, fetch }
}

測試(mock axios)

// tests/unit/useFetchUser.spec.js
import { useFetchUser } from '../../src/composables/useFetchUser'
import axios from 'axios'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { nextTick } from 'vue'

vi.mock('axios')

describe('useFetchUser', () => {
  const mockUser = { id: 1, name: 'Alice' }

  beforeEach(() => {
    axios.get.mockReset()
  })

  it('成功取得使用者資料', async () => {
    axios.get.mockResolvedValueOnce({ data: mockUser })
    const { user, loading, error } = useFetchUser(1)

    // 初始狀態
    expect(loading.value).toBe(true)
    expect(user.value).toBeNull()
    expect(error.value).toBeNull()

    // 等待非同步執行結束
    await vi.runAllTimersAsync()
    await nextTick()

    expect(loading.value).toBe(false)
    expect(user.value).toEqual(mockUser)
    expect(error.value).toBeNull()
    expect(axios.get).toHaveBeenCalledWith('/api/users/1')
  })

  it('發生錯誤時會設定 error', async () => {
    const err = new Error('Network error')
    axios.get.mockRejectedValueOnce(err)

    const { error, loading } = useFetchUser(2)

    await vi.runAllTimersAsync()
    await nextTick()

    expect(loading.value).toBe(false)
    expect(error.value).toBe(err)
  })
})

要點

  • 使用 vi.mock('axios') 讓所有 axios.get 呼叫被 mock。
  • vi.runAllTimersAsync() 會快進所有被 setTimeout 包裝的 Promise(Vitest 預設使用偽時鐘)。
  • 測試中同時檢查 loadingusererror 三個狀態,確保 composable 的完整行為。

範例 4:結合 Pinia Store 的 composable

// src/stores/todoStore.js
import { defineStore } from 'pinia'

export const useTodoStore = defineStore('todo', {
  state: () => ({
    list: [] as string[]
  }),
  actions: {
    add(item) {
      this.list.push(item)
    },
    clear() {
      this.list = []
    }
  }
})
// src/composables/useTodoFilter.js
import { computed } from 'vue'
import { useTodoStore } from '../stores/todoStore'

export function useTodoFilter(keyword = '') {
  const store = useTodoStore()
  const filtered = computed(() => {
    if (!keyword) return store.list
    return store.list.filter((t) => t.includes(keyword))
  })

  return { filtered }
}

測試(Pinia 的測試環境)

// tests/unit/useTodoFilter.spec.js
import { setActivePinia, createPinia } from 'pinia'
import { useTodoStore } from '../../src/stores/todoStore'
import { useTodoFilter } from '../../src/composables/useTodoFilter'
import { nextTick } from 'vue'

describe('useTodoFilter', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('未傳入 keyword 時回傳全部 todo', async () => {
    const store = useTodoStore()
    store.add('Buy milk')
    store.add('Write article')
    const { filtered } = useTodoFilter()
    expect(filtered.value).toEqual(['Buy milk', 'Write article'])
  })

  it('傳入 keyword 時只回傳符合的項目', async () => {
    const store = useTodoStore()
    store.add('Buy milk')
    store.add('Write article')
    store.add('Read book')
    const { filtered } = useTodoFilter('Write')
    expect(filtered.value).toEqual(['Write article'])
  })
})

說明:在測試 Pinia 時,必須先 setActivePinia(createPinia()) 讓 Store 能夠被正確初始化,否則 useTodoStore() 會拋出 “Pinia has not been installed” 的錯誤。


範例 5:可組合的 debounce hook(含 timer mock)

// src/composables/useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
  const debounced = ref(value)

  let timeout
  watch(
    () => value,
    (newVal) => {
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        debounced.value = newVal
      }, delay)
    },
    { immediate: true }
  )

  return { debounced }
}

測試(偽造 timer)

// tests/unit/useDebounce.spec.js
import { useDebounce } from '../../src/composables/useDebounce'
import { ref, nextTick } from 'vue'
import { vi, describe, it, expect, beforeEach } from 'vitest'

describe('useDebounce', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  it('在 delay 時間內不會更新 debounced', async () => {
    const source = ref('a')
    const { debounced } = useDebounce(source, 500)

    expect(debounced.value).toBe('a')
    source.value = 'b'
    await nextTick()

    // 時間尚未過 500ms
    vi.advanceTimersByTime(300)
    expect(debounced.value).toBe('a')

    // 再過 200ms
    vi.advanceTimersByTime(200)
    expect(debounced.value).toBe('b')
  })

  it('多次快速變更只會觸發最後一次', async () => {
    const source = ref(0)
    const { debounced } = useDebounce(source, 400)

    source.value = 1
    await nextTick()
    source.value = 2
    await nextTick()
    source.value = 3
    await nextTick()

    // 只前進 399ms,仍保持舊值
    vi.advanceTimersByTime(399)
    expect(debounced.value).toBe(0)

    // 前進 1ms,最後一次的值才會套用
    vi.advanceTimersByTime(1)
    expect(debounced.value).toBe(3)
  })
})

關鍵vi.useFakeTimers() 讓測試可以精準控制 setTimeout 的執行時機,避免因真實時間等待而拖慢測試速度。


常見陷阱與最佳實踐

陷阱 說明 解決方案 / 最佳實踐
未重置 mock 同一個 mock 被多個測試共用,導致呼叫次數累計錯誤。 afterEachbeforeEach 使用 vi.restoreAllMocks() / mockReset()
直接使用瀏覽器 API window, document, localStorage 在 Node 測試環境中不存在。 使用 vi.stubGlobaljsdom 提供的 polyfill,或把依賴抽成可注入的服務。
測試非同步更新時未等待 Vue 的回應式更新是微任務,直接斷言會得到舊值。 使用 await nextTick()await flushPromises()await vi.runAllTimersAsync()
測試 composable 時直接寫在 component 中 失去 composable 的獨立性,測試變得笨重。 只在測試檔中呼叫 composable,不需要掛載任何 Vue 元件。
依賴外部庫(如 axios)未 mock 真實的 HTTP 請求會導致測試慢且不穩定。 使用 vi.mock('axios')msw(Mock Service Worker)來攔截請求。
Timer/interval 未偽造 setTimeout/setInterval 會讓測試卡住。 vi.useFakeTimers() 搭配 vi.advanceTimersByTime()

最佳實踐總結

  1. 保持 composable 純粹:盡量把副作用(如 DOM 操作、全域狀態)抽離成可注入的服務,讓測試只聚焦於回傳值與行為。
  2. 使用 TypeScript (若有):型別可以在編譯階段捕捉錯誤,減少測試時的防呆程式碼。
  3. 測試可讀性:測試名稱要描述「行為」而非「實作」,例如 should increase count by step 而不是 should call inc.
  4. 保持測試速度:只 mock 必要的依賴,避免在單元測試中使用真實的 HTTP、DB 或大型 UI 渲染。
  5. 持續整合(CI):在 CI pipeline 中加入 vitest run --coverage,確保每次提交都有測試覆蓋率報告。

實際應用場景

場景 可能的 composable 測試重點
表單驗證 useFormValidator(驗證規則、錯誤訊息) 輸入不同值的驗證結果、錯誤訊息文字、reset 方法是否清除狀態。
無限捲動 useInfiniteScroll(監聽 scroll、載入下一頁) 滾動觸發時 fetchNext 是否被呼叫、loading 狀態切換、邊界條件(已無更多資料)。
權限控制 usePermission(根據角色返回布林值) 不同角色的返回值、動態變更角色時的即時更新、與路由守衛的整合測試。
多語系切換 useI18nLocale(切換 locale、載入語系檔案) 切換語系後 t 函式回傳正確字串、localStorage 持久化、非同步載入失敗的錯誤處理。
即時資料流 useWebSocket(連線、訊息接收、斷線重連) 連線成功與失敗的事件、訊息處理回調、斷線後的自動重連機制。

以上場景在大型專案中極為常見。將這些功能抽成 composable 後,只要寫好單元測試,就能在功能迭代時保持高信心,減少回歸缺陷。


總結

測試 composable 並非額外負擔,而是 提升專案品質的關鍵投資。本文從測試的必要性、工具選型、五個實作範例,到常見陷阱與最佳實踐,提供了一套完整的測試流程。掌握以下要點,即可在日常開發中快速為每個 composable 建立可靠的測試:

  1. 建立乾淨的測試環境(Vitest + Vue Test Utils)。
  2. 使用 mock 代替外部依賴(axios、localStorage、timer)。
  3. 透過 nextTick / flushPromises 確保回應式更新完成
  4. 保持 composable 的純粹與可注入,讓測試只關注輸入/輸出。
  5. 在 CI 中持續跑測試與覆蓋率,讓品質保證成為自動化的一環。

只要把測試納入開發流程,未來即使面對複雜的業務需求或重構,也能自信地說:「我的 composable 已經被驗證過了」。祝你在 Vue3 的組合式 API 世界中寫出乾淨、可測、可維護的程式碼!