單元測試入門完整指南
為什麼寫 + 怎麼寫
不寫測試的工程師最常說「沒時間」 ─ 真實原因是「沒養成習慣」。一旦養成、寫測試的時間會被「節省的 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:
- 你先寫 function
- 請 AI 列出「這個 function 有哪些 edge case 需要測」
- 你 review 這份清單、加上 AI 沒想到的
- 請 AI 寫測試 code
- 你 review 測試 code、確認真的有測到
重點 ─ 你還是要 review。AI 寫的測試可能看起來對、但漏掉關鍵 case。
最後一個提醒
寫測試不是多做一件事、是用不同方式做同一件事。
它是你明天的自己留給今天的自己的禮物。
一旦養成「寫完 function 就寫測試」的習慣、你會發現:
- refactor 時你不再害怕
- 上線時你不再焦慮
- 新功能加得比沒測試的同事快 2 倍
- 面試聊到你 project、能講出「為什麼這樣設計」
這個習慣的養成期 ─ 3 個月。撐過去、終身受用。