練習撰寫測試
以下題目將對一個待辦清單 app 分別進行單元測試與整合測試,待辦清單的功能如下所述:
✏️ Todo list 功能需求
- 可查看所有待辦列表
- 輸入待辦事項文字,點擊新增,可新增一筆待辦
- 如未輸入就點擊新增按鈕,無法新增待辦,輸入框須變更為異常狀態,並顯示提示「請輸入待辦事項」
- 新增的 todo 預設為未完成狀態
- 輸入框的異常狀態在下次輸入時清除
- 點擊 todo 的勾選框可切換待辦的完成狀態
- 已完成的項目文字會出現刪除線
- 點擊 todo 的刪除按鈕可刪除待辦
環境建置
在開始測試前,請先
- 建立一個新專案,安裝並引入以下測試工具
- 簡單查閱 vitest 介紹與用法
- vue3 + vite
- vitest
- @vue/test-utils
一、單元測試練習
在該階段的練習中,你將會進行:
- 撰寫一個 todo list 的功能邏輯 composable
- 了解一個功能中適合單元測試的部分
- 對第一步完成的 composable 撰寫測試
- 理解如何精確的撰寫測試描述
- 學習 vitest 用法,撰寫測試
🧪 01 - 以 composable 形式封裝 todo 功能
📋 目標
撰寫一個 useTodo() 的 Composable,用來管理 todo 清單。此練習將聚焦在撰寫可測試的邏輯,不涉及元件或 UI。
✅ 功能需求
useTodo() 應回傳以下項目:
{
todos, // 所有 todo 的 ref 陣列
addTodo(text), // 新增一筆 todo,預設為未完成
removeTodo(index), // 根據 index 刪除一筆 todo
toggleTodo(index) // 切換指定 todo 的完成狀態
}🚀參考解答
Details
js
// useTodo.js
import { ref } from 'vue'
export function useTodo() {
const todos = ref([])
function addTodo(text) {
if (!text || text.trim() === '') return false
todos.value.push({ text, done: false })
return true
}
function removeTodo(index) {
if (index < 0 || index >= todos.value.length) return false
todos.value.splice(index, 1)
return true
}
function toggleTodo(index) {
if (index < 0 || index >= todos.value.length) return false
todos.value[index].done = !todos.value[index].done
return true
}
return {
todos,
addTodo,
removeTodo,
toggleTodo
}
}🧪 02 - 撰寫 useTodo 的單元測試
請針對你在前一題撰寫的 useTodo composable 使用 vitest api 撰寫單元測試,練習如何針對一個具有狀態的邏輯模組撰寫測試,並思考測試可讀性與測試項目的設計原則。
📋 目標
- 學會如何針對包含狀態的 Composable 撰寫單元測試
- 學會「一個測試只測一件事」的原則
- 能夠從功能需求中整理出測試項目
- 能觀察函式是否易於測試(testable)並思考是否需要重構
🧩 要求
- 你應該對下列功能分別撰寫測試
- 新增 todo
- 刪除 todo
- 切換 todo 完成狀態
- 每個功能的測試項目需涵蓋到功能的基本行為
- 每個測試項目都應包含:
- 測試描述(test 名稱)
- 測試輸入(前置條件)
- 驗證結果(expect)
如何寫好測試描述?
✅ 小技巧: 如何確認單元測試涵蓋了功能的基本行為?
測試一個函式的核心在於確認「輸入」與「輸出」是否符合預期。實務上,任何可能影響輸出的參數或邏輯,都可以歸納為「輸入」的一部分。因此,能否找出所有可能的輸入情境,是確保測試涵蓋功能基本行為的關鍵。
🧩【方法一】你可以從測試的三個面向觀察、設計測試:
- 輸入(函式的參數)
- 確認所有可能的輸入情境,包括有效值、無效值、邊界條件(如最大值、最小值、空值等)。
- 確認是否有預設值的情況,例如:可選參數的預設值、具有預設值的變數等。
- 執行(對輸入的處理過程)
- 檢查邏輯中是否對輸入有額外的限制,例如型別驗證、格式檢查、上下限等。
- 確認是否存在異常處理或錯誤分支,例如拋出例外、回傳特定錯誤值。
- 輸出(函式結果)
- 透過結果反推,是否已覆蓋所有邏輯分支與輸出狀態。
- 若有副作用(如資料寫入、狀態變更等),應額外驗證其影響。
🧩【方法二】從「功能需求」出發整理測試情境
另一個實用的方式是直接從功能需求或設計目的出發,思考函式在各種使用情境下應該表現的行為,進而規劃測試項目。 這種方式特別適用於具有「明確業務邏輯」或「多種行為分支」的函式。
範例:計算商品折扣的函式
ts
// 功能需求:
// =>「會員享 10% 折扣,非會員不折扣,金額不可為負」
// 依據需求撰寫的函式
function calculateDiscount(price: number, isMember: boolean): number {
if (price < 0) throw new Error('Invalid price');
let discount = isMember ? 0.1 : 0;
return price * (1 - discount);
}🔍 從需求拆解出的測試情境
根據功能需求,我們可以列出:
- 價格為正數 + 是會員 → 回傳打 9 折的金額
- 價格為正數 + 非會員 → 回傳原價
- 價格為 0 → 回傳 0(邊界值)
- 價格為負數 → 拋出錯誤
相同的邏輯其實也能套用到其他測試上,觀察所有可能的「輸入」可以幫助我們更全面地設計測試項目。但要注意的是,測試項目不一定是越多越好,只要能涵蓋功能的基本行為,就已經達到撰寫測試的目的了。
不知道怎麼開始寫?
👉 複習上一章 測試撰寫流程
🚀參考解答
Details
第一步: 根據功能需求整理出測試項
- 新增 todo
- should add todo when given valid text
- should not add todo when text is empty
- should create new todo as not done by default
- 刪除 todo
- should delete todo by index
- should do nothing when deleting non-existing index
- 切換 todo 完成狀態
- should toggle todo done state
- should do nothing when toggling non-existing index
第二步,依據 3A 法則來寫測試檔
js
// useTodo.test.js
import { useTodo } from './useTodo'
import { describe, expect, it } from 'vitest'
describe('useTodo', () => {
// 新增 todo
it('should add todo when given valid text', () => {
const { todos, addTodo } = useTodo()
const result = addTodo('Buy milk')
expect(todos.value.length).toBe(1)
expect(todos.value[0].text).toBe('Buy milk')
expect(result).toBe(true)
})
it('should return false when text is empty', ()=>{
const { todos, addTodo } = useTodo()
const result = addTodo('')
expect(result).toBe(false)
})
it.each([
['', 'empty string'],
[' ', 'single space'],
])('should not add todo when text is "%s" as %s', (input) => {
const { todos, addTodo } = useTodo()
addTodo(input)
expect(todos.value.length).toBe(0)
})
it('should create new todo as not done by default', () => {
const { todos, addTodo } = useTodo()
addTodo('Buy milk')
expect(todos.value[0].done).toBe(false)
})
// 刪除 todo
it('should delete todo by index', () => {
const { todos, addTodo, deleteTodo } = useTodo()
addTodo('Task 1')
addTodo('Task 2')
deleteTodo(0)
expect(todos.value.length).toBe(1)
expect(todos.value[0].text).toBe('Task 2')
})
it('should do nothing when deleting non-existing index', () => {
const { todos, addTodo, deleteTodo } = useTodo()
addTodo('Task 1')
deleteTodo(99)
expect(todos.value.length).toBe(1)
expect(todos.value[0].text).toBe('Task 1')
})
// 切換 todo 完成狀態
it('should toggle todo done state', () => {
const { todos, addTodo, toggleTodo } = useTodo()
addTodo('Sleep')
toggleTodo(0)
expect(todos.value[0].done).toBe(true)
toggleTodo(0)
expect(todos.value[0].done).toBe(false)
})
it('should do nothing when toggling non-existing index', () => {
const { todos, addTodo, toggleTodo } = useTodo()
addTodo('Task 1')
toggleTodo(10)
expect(todos.value.length).toBe(1)
expect(todos.value[0].done).toBe(false)
})
})