為什麼要測試

資訊

這個分類的所有文章皆以 TypeScript 搭配 Vitest 示範。

測試的目的是保證程式碼品質的下限,沒有測試的程式碼近乎沒有下限,通常會耗費更多時間來定位 bug,也不容易根除問題。

假設我們定義好了一些流程,例如:

  • 檢查使用者輸入的 email 是否已被註冊過,才允許下一步的流程
  • 日期資料沒有符合 'YYYY-MM-DD' 就拋出錯誤

測試的程式碼主要用來檢驗這些流程的運行結果是否符合預期。


測試的類型

單元測試

單元測試 (Unit Test) 的規模可以是一個函式、類別、模組,主要針對小範圍的邏輯。

所以:規模小 = 容易撰寫 = 最快看到收益

單元測試專注在驗證單一功能的各類情境,因此該功能需要呼叫的外部程式碼大多會使用假資料模擬。

例如:

A 功能需要的資料是呼叫來自其他模組的 B 函式得到,這時會模擬一個假的 B 函式,並假定 B 函式會產生出指定資料。測試運行時就會呼叫模擬的 B 函式,所以 A 功能的測試只需要專注在拿到各種資料時怎麼輸出。

案例:

  • 後端:repository、service 等業務邏輯的資料操作
  • 前端:hooks、UI 元件的狀態變化

整合測試

整合測試 (Integration Test) 需要驗證多個模組的交互流程,通常是用來測試一些重要的業務邏輯是否正確運行,所以假資料的模擬會更少。

案例:

  • 後端:API 呼叫 → service → repository → 資料庫的完整流程
  • 前端:表單提交 → API 呼叫 → 狀態更新 → UI 渲染

端對端測試

端對端測試 (End to End Test) 會以使用者的角度來驗證完整操作流程。

後端要保證 API 端點的存取流程,前端要模擬使用者的 UI 操作流程。所以嚴格來說,請一個人實際從畫面上操作功能也是一種 E2E 測試(全民公測)。

案例:

  • 後端:註冊 → 填寫使用者資料 → 寫入資料庫返回完整的使用者資料
  • 前端:模擬使用者點擊、輸入、導航的完整操作

覆蓋率

覆蓋率 (Coverage) 是指測試的程式碼執行時涵蓋了多少原始的程式碼,常見指標包括:

  • 行覆蓋率 (Line Coverage):執行過的程式碼行數比例
  • 分支覆蓋率 (Branch Coverage):執行過的條件分支比例 (if / else)
  • 函式覆蓋率 (function Coverage):執行過的函式比例
  • 語句覆蓋率 (Statement Coverage):最常用的指標,雖然也是看行數,但與行覆蓋率不一樣的是同一行裡面有三元運算、短路等等的連續判斷,只會計算到有被測試程式碼執行過的部分
警告

覆蓋率高 ≠ 測試品質好,重點是該功能的測試方向是否符合需求。


起手式

測試區塊

輸出兩數總和:

export function sum(a: number, b: number): number {
  return a + b;
}

要測試的功能會使用 describe 包住,稱為測試區塊 (test suite):

describe('sum 函式', () => {
  it('1 加 2 應該等於 3', () => {
    expect(sum(1, 2)).toBe(3);
  });

  it('1 加 -1 應該等於 0', () => {
    expect(sum(1, -1)).toBe(0);
  });
});

測試區塊中會使用 it / test(功能相同) 來列舉要測試的情境,稱為測試案例 (test case)。

describeit 的參數結構相同,第一個參數可以輸入文字描述,英文的描述慣例是 should...

測試的運行結果也會將這段描述顯示出來:

gh


3A Pattern

3A 是測試的撰寫慣例:

  1. Arrange(準備): 設定測試資料
  2. Act(執行): 呼叫要測試的函數
  3. Assert(驗證): 檢查結果是否符合預期

測試案例通常都按此結構撰寫!

以下的函式會輸出陣列中所有數字的總和:

export function sumArray(arr: number[]): number {
  return arr.reduce((acc, curr) => acc + curr, 0);
}

依照 3A 將測試的程式碼分出區塊:

describe('sumArray 函式', () => {
  it('[1, 2, 3] 應該等於 6', () => {
    // Arrange
    const mockInput = [1, 2, 3];

    // Act
    const result = sumArray(mockInput);

    // Assert
    expect(result).toBe(6);
  });
});
資訊

測試案例需要確保每個案例都要能獨立執行,所以要模擬什麼部分、資料清理的週期都需要好好思考。如果測試案例執行之間會互相干擾測試資料與結果,整個測試就是不穩定、不準確的。


制定測試案例

測試的目的在於驗證功能行為是否符合預期,而不是在強調每個步驟都要做對,反過來說,先確定這個行為應該造成什麼結果,就會制定出正確的步驟。

所以測試案例不需要窮舉。

例如範例的 sumArray,因為只有一個參數,測試案例只需要:

  • 確認這個函式會回傳一個正確加總過的數字
  • 傳入空陣列會回傳什麼
  • 傳入非陣列的參數會回傳什麼(這會在靜態檢查階段擋下,通常不需要測試這個案例)

小結

gh

標準的測試金字塔會長這樣,可以直覺地對應到:

  • 單元 = 顆粒小 = 數量多
  • 端對端 = 顆粒大 = 數量少

但這是「標準」狀況,實際開發會歷經許多階段和突發狀況,會調整各種測試類型的比例,因此有個重要守則:

不要在需求不明確的情況下開始構思測試策略。

雖然「越早導入測試越好」是一個良好習慣,但像是 PoC 這種專案輪廓還沒有成形的階段,會反覆地探索需求、測試可行性與市場價值,這個時期的需求會快速變動,可能過一晚又推翻了,這時候去拚測試的覆蓋率意義不大,還是可以寫,但是先圍繞在核心業務邏輯來測試。


參考資料