Skip to content

練習撰寫測試

以下題目將對一個待辦清單 app 分別進行單元測試與整合測試,待辦清單的功能如下所述:

✏️ Todo list 功能需求

  • 可查看所有待辦列表
  • 輸入待辦事項文字,點擊新增,可新增一筆待辦
  • 如未輸入就點擊新增按鈕,無法新增待辦,輸入框須變更為異常狀態,並顯示提示「請輸入待辦事項」
  • 新增的 todo 預設為未完成狀態
  • 輸入框的異常狀態在下次輸入時清除
  • 點擊 todo 的勾選框可切換待辦的完成狀態
  • 已完成的項目文字會出現刪除線
  • 點擊 todo 的刪除按鈕可刪除待辦

環境建置

在開始測試前,請先

  1. 建立一個新專案,安裝並引入以下測試工具
  2. 簡單查閱 vitest 介紹與用法

一、單元測試練習

在該階段的練習中,你將會進行:

  • 撰寫一個 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)
  })
})