回到 Blog 中文 EN

單元測試入門完整指南
為什麼寫 + 怎麼寫

不寫測試的工程師最常說「沒時間」 ─ 真實原因是「沒養成習慣」。一旦養成、寫測試的時間會被「節省的 debug 時間」抵掉、總時間反而變短。這篇拆給你看:為什麼寫、第一個測試怎麼寫、framework 怎麼選、覆蓋率該追多少。

先打掉4 個迷思

01 「寫測試很花時間」

短期看是 ─ 你多花 30% 時間寫測試。

長期看相反 ─ 沒測試的 code 你 3 個月後改一處要花 1 小時 debug、有測試的 5 分鐘搞定。

總帳:寫測試的工程師總時間花得比較少

02 「測試是之後再補的事」

「之後再補」 = 永遠不補。

原因:測試不是寫完 code 就能補。沒寫測試的 code 通常難測(耦合太多、副作用太多)─ 要補測試你得先 refactor、阻力比寫的時候大 5 倍。

所以、要不就當下寫、要不就永遠不寫。沒有中間。

03 「覆蓋率越高越好」

錯。

100% 覆蓋率的 code 可能還是有 bug、80% 覆蓋率的 code 可能很穩。

重點不是「數字」、是「有沒有覆蓋關鍵路徑跟 edge case」。

04我手動測過、不用寫測試

手動測試解決「現在」的問題。
自動測試解決「3 個月後改 code」的問題。

你今天手動測過、明天忘了改了什麼、後天 bug 上線、再隔天找原因花 3 小時 ─ 這 3 小時 = 你當初省下沒寫測試的 30 分鐘。

划不來。


什麼是「單元測試

單元測試的「單元」指:

  • 1 個 function
  • 1 個 class
  • 1 個 component(React/Vue)
  • 1 個 module 的 1 個責任

特徵:

  • 跑得快(每個測試 < 100ms)
  • 不碰外部(不打 DB、不打 API、不讀檔)
  • 結果可預期(同樣輸入永遠同樣輸出)
  • 互相獨立(A 測試壞、B 不會壞)

測試金字塔 ─ 3 層

層級 數量比例 測什麼 跑多快
Unit Test 70% 單一 function / module 毫秒
Integration Test 20% 多個 module 合作(含 DB) 百毫秒到秒
E2E Test 10% 整個 user flow(含瀏覽器) 秒到分

多數初學者搞錯比例 ─ 寫了一堆 E2E、沒寫 unit。結果測試跑 30 分鐘、出問題不知道原因在哪。

理想是「金字塔」:unit 多、E2E 少。


第一個測試怎麼寫

選 framework

以 JavaScript/TypeScript 為例:

  • Vitest ─ 新專案首選、速度快、跟 Vite 整合好
  • Jest ─ React 生態系最熟、教學資源最多
  • Node Test Runner ─ Node 18+ 內建、零依賴(適合 library 作者)

新專案我推薦 Vitest。語法跟 Jest 90% 一樣、速度快 5 倍。

裝起來

npm i -D vitest
# 在 package.json
"scripts": {
  "test": "vitest run",
  "test:watch": "vitest"
}

第一個測試

假設你有一個 function:

// src/lib/calculate.ts
export function calculateTax(price: number, rate: number): number {
  if (price < 0) throw new Error('Price cannot be negative')
  return price * (1 + rate)
}

測試:

// src/lib/calculate.test.ts
import { describe, it, expect } from 'vitest'
import { calculateTax } from './calculate'

describe('calculateTax', () => {
  it('回傳含稅價格', () => {
    expect(calculateTax(100, 0.05)).toBe(105)
  })

  it('rate 為 0 時、價格不變', () => {
    expect(calculateTax(100, 0)).toBe(100)
  })

  it('price 為負數時、丟錯誤', () => {
    expect(() => calculateTax(-1, 0.05)).toThrow('Price cannot be negative')
  })
})

跑:

npm test

# 結果
✓ src/lib/calculate.test.ts (3 tests)
  ✓ calculateTax
    ✓ 回傳含稅價格
    ✓ rate 為 0 時、價格不變
    ✓ price 為負數時、丟錯誤

恭喜、你有第一個測試了。


好測試的5 個特徵

01 一個測試只測一件事

錯誤示範:

it('calculateTax works', () => {
  expect(calculateTax(100, 0.05)).toBe(105)
  expect(calculateTax(100, 0)).toBe(100)
  expect(() => calculateTax(-1, 0.05)).toThrow()
})

問題 ─ 第一個 assertion 壞了、後面不會跑、你看不到全貌。

正確 ─ 每個 case 一個 it

02 測試名要描述行為、不是函式名

  • it('calculateTax')
  • it('test 1')
  • it('回傳含稅後的價格')
  • it('當 price 為負數時、丟錯誤')

測試名是給看 test report 的人讀的。要讀得出來「應該怎樣」。

03 Arrange / Act / Assert ─ 3 段式結構

it('套用 10% 折扣後、價格降低 10%', () => {
  // Arrange:準備資料
  const order = { items: [{ price: 100, qty: 2 }] }
  const discount = 0.1

  // Act:執行被測試的行為
  const total = applyDiscount(order, discount)

  // Assert:驗證結果
  expect(total).toBe(180)
})

這個結構讓任何人看你的測試、5 秒內懂在做什麼。

04邊界、不只測「正常情況

多數新手只寫 happy path。Senior 工程師會額外寫:

  • 邊界值:0、1、-1、最大值、最小值
  • 空輸入:empty array、empty string、null、undefined
  • 錯誤輸入:型別錯、值超出範圍
  • 並發:同時呼叫的情況

05 測試不依賴順序

錯誤示範:

let user
it('創建 user', () => {
  user = createUser('A')   // 第 1 個測試
  expect(user.id).toBe(1)
})

it('user 還在', () => {
  expect(user.id).toBe(1)  // 依賴第 1 個測試
})

錯了 ─ 兩個測試共用 state、單跑第 2 個會壞。

正確 ─ 每個測試都要能單獨跑。用 beforeEach 重設 state:

describe('User', () => {
  let user
  beforeEach(() => {
    user = createUser('A')   // 每個測試前重設
  })

  it('user id 是 1', () => {
    expect(user.id).toBe(1)
  })

  it('user name 是 A', () => {
    expect(user.name).toBe('A')
  })
})

什麼該測、什麼不該測

該測

  • 商業邏輯(計算、規則、判斷)
  • 邊界條件(最大、最小、空、null)
  • 錯誤處理(throw 哪些 error、什麼時候 throw)
  • 關鍵 user flow(登入、付款、註冊)

不該測(或低優先)

  • 第三方 library(已經被原作者測過)
  • 純 getter/setter(沒邏輯)
  • 純 UI 渲染(用 visual test / snapshot 即可)
  • config 檔

原則 ─ 測「容易壞、壞了影響大」的部分。不測「不會壞、壞了沒差」的部分。


覆蓋率該追多少

這是最被吵的問題。給你務實答案:

  • 純 hobby project ─ 不用追數字、有 1 個就好
  • 正式產品、初期 ─ 50-60%(covering 核心邏輯)
  • 正式產品、成熟 ─ 70-80%
  • 關鍵 module(付款、認證) ─ 90%+
  • 新人初學 ─ 從 30% 開始、慢慢加

重點 ─ 覆蓋率是「檢查工具」、不是「目標

盯著「100% 覆蓋率」的 team、通常寫一堆爛測試湊數。


常見的4 個誤區

01 測 implementation、不是行為

錯:

it('呼叫 calculateTax 內部用了 multiply', () => {
  const spy = vi.spyOn(math, 'multiply')
  calculateTax(100, 0.05)
  expect(spy).toHaveBeenCalled()
})

問題 ─ 你 refactor 把 multiply 換成 add + loop、測試就壞了。但實際行為沒變

正確 ─ 測「給輸入、回什麼」、不測「內部用了什麼 function」。

02 Mock 太多

每個 dependency 都 mock 的測試 ─ 跑得過、實際整合就爆。

原則:

  • Mock 外部資源(DB、API、第三方)
  • 不要 Mock 自己的 module(除非真的需要隔離)

03 寫測試只為了通過、不為了抓 bug

// 為了通過寫的測試
it('returns something', () => {
  const result = calculate()
  expect(result).toBeDefined()
})

這個測試永遠會過、但抓不到任何 bug。

好測試的判斷 ─「如果我把這段 code 改壞、這個測試會 fail 嗎?」答案是「會」才有用。

04 不維護爛測試

常見:CI 上某個測試常常 flaky(隨機壞)、大家學會「重 run 就好」。

這是慢性自殺。Flaky test 會教 team「測試結果不重要」、然後真的有 bug 時、紅了大家也不在乎。

發現 flaky test ─ 立刻修或刪掉、不要放著。


AI 時代寫測試

AI 工具(Cursor / Claude Code)寫單元測試效率超高。建議 workflow:

  1. 你先寫 function
  2. 請 AI 列出「這個 function 有哪些 edge case 需要測
  3. 你 review 這份清單、加上 AI 沒想到的
  4. 請 AI 寫測試 code
  5. 你 review 測試 code、確認真的有測到

重點 ─ 你還是要 review。AI 寫的測試可能看起來對、但漏掉關鍵 case。


最後一個提醒

寫測試不是多做一件事、是用不同方式做同一件事
它是你明天的自己留給今天的自己的禮物。

一旦養成「寫完 function 就寫測試」的習慣、你會發現:

  • refactor 時你不再害怕
  • 上線時你不再焦慮
  • 新功能加得比沒測試的同事快 2 倍
  • 面試聊到你 project、能講出「為什麼這樣設計」

這個習慣的養成期 ─ 3 個月。撐過去、終身受用。

已經寫測試但越寫越混亂?

30 分鐘 1-on-1 諮詢 NT$1,500 ─ 我看你最近一個 PR、幫你拆「哪邊該測、哪邊不用、怎麼整理」。

LINE 預約諮詢 先訂閱 Newsletter