Vue3 單元:組合式函式(Composables) – 測試 composable
簡介
在 Vue3 中,組合式 API 讓開發者可以把可重用的邏輯抽離成獨立的函式(亦稱 composable),從而提升程式碼的可維護性與可測試性。雖然 composable 的撰寫相對簡單,但如果缺乏系統化的測試,未來的功能調整或重構很容易引入隱蔽的 bug。
本篇文章將說明 如何為 composable 撰寫單元測試,涵蓋測試環境的建置、常見測試技巧、以及在實務專案中如何將測試流程落實。無論你是剛接觸 Vue3 的新手,或是已有一定開發經驗的中階工程師,都能從中獲得可直接套用的範例與最佳實踐。
核心概念
1. 為什麼要測試 composable?
| 優點 | 說明 |
|---|---|
| 獨立性 | composable 本身不依賴 Vue 元件的模板,測試時可只聚焦於邏輯本身。 |
| 可重用性 | 多個元件共用同一個 composable,測試一次即可保證所有使用者的行為正確。 |
| 防止回歸 | 任何對 composable 的改動,都會即時在測試中捕捉到不預期的變化。 |
| 文件化 | 測試碼本身就是使用說明,閱讀測試可快速了解 composable 的 API 與預期行為。 |
2. 測試工具選型
| 工具 | 特色 | 常見搭配 |
|---|---|---|
| Vitest | 輕量、原生支援 ES 模組、與 Vite 無縫整合。 | @vue/test-utils、pinia |
| Jest | 生態成熟、內建快照測試、豐富的 mock 功能。 | vue-jest、babel-jest |
| Vue Test Utils | 官方提供的 Vue 元件測試工具,亦可用於測試 composable(透過 shallowMount 或 render) |
Vitest / Jest |
本文示範以 Vitest 為例,因其設定簡潔且與 Vue3/Vite 項目高度相容。若你仍在使用 Jest,只要把 vitest 替換成 jest,概念與程式碼幾乎相同。
3. 測試 composable 的基本步驟
- 建立測試環境:安裝
vitest、@vue/test-utils、@vue/reactivity(若需要手動觸發回應式更新)。 - 匯入 composable:在測試檔中直接 import 目標函式。
- 呼叫 composable:通常在
setup內執行,返回的 state、computed、method 都可以直接使用。 - 斷言結果:使用
expect斷言值、函式呼叫次數、或是 side‑effect(例如 localStorage、API 呼叫)。 - 清理:在
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 預設使用偽時鐘)。- 測試中同時檢查
loading、user、error三個狀態,確保 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 被多個測試共用,導致呼叫次數累計錯誤。 | 在 afterEach 或 beforeEach 使用 vi.restoreAllMocks() / mockReset()。 |
| 直接使用瀏覽器 API | window, document, localStorage 在 Node 測試環境中不存在。 |
使用 vi.stubGlobal 或 jsdom 提供的 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()。 |
最佳實踐總結
- 保持 composable 純粹:盡量把副作用(如 DOM 操作、全域狀態)抽離成可注入的服務,讓測試只聚焦於回傳值與行為。
- 使用 TypeScript (若有):型別可以在編譯階段捕捉錯誤,減少測試時的防呆程式碼。
- 測試可讀性:測試名稱要描述「行為」而非「實作」,例如
should increase count by step而不是should call inc. - 保持測試速度:只 mock 必要的依賴,避免在單元測試中使用真實的 HTTP、DB 或大型 UI 渲染。
- 持續整合(CI):在 CI pipeline 中加入
vitest run --coverage,確保每次提交都有測試覆蓋率報告。
實際應用場景
| 場景 | 可能的 composable | 測試重點 |
|---|---|---|
| 表單驗證 | useFormValidator(驗證規則、錯誤訊息) |
輸入不同值的驗證結果、錯誤訊息文字、reset 方法是否清除狀態。 |
| 無限捲動 | useInfiniteScroll(監聽 scroll、載入下一頁) |
滾動觸發時 fetchNext 是否被呼叫、loading 狀態切換、邊界條件(已無更多資料)。 |
| 權限控制 | usePermission(根據角色返回布林值) |
不同角色的返回值、動態變更角色時的即時更新、與路由守衛的整合測試。 |
| 多語系切換 | useI18nLocale(切換 locale、載入語系檔案) |
切換語系後 t 函式回傳正確字串、localStorage 持久化、非同步載入失敗的錯誤處理。 |
| 即時資料流 | useWebSocket(連線、訊息接收、斷線重連) |
連線成功與失敗的事件、訊息處理回調、斷線後的自動重連機制。 |
以上場景在大型專案中極為常見。將這些功能抽成 composable 後,只要寫好單元測試,就能在功能迭代時保持高信心,減少回歸缺陷。
總結
測試 composable 並非額外負擔,而是 提升專案品質的關鍵投資。本文從測試的必要性、工具選型、五個實作範例,到常見陷阱與最佳實踐,提供了一套完整的測試流程。掌握以下要點,即可在日常開發中快速為每個 composable 建立可靠的測試:
- 建立乾淨的測試環境(Vitest + Vue Test Utils)。
- 使用 mock 代替外部依賴(axios、localStorage、timer)。
- 透過
nextTick/flushPromises確保回應式更新完成。 - 保持 composable 的純粹與可注入,讓測試只關注輸入/輸出。
- 在 CI 中持續跑測試與覆蓋率,讓品質保證成為自動化的一環。
只要把測試納入開發流程,未來即使面對複雜的業務需求或重構,也能自信地說:「我的 composable 已經被驗證過了」。祝你在 Vue3 的組合式 API 世界中寫出乾淨、可測、可維護的程式碼!