- Published on
單元測試和 TDD 之間的關係

測試系列文章
- Test Double 測試替身入門:五種類型一次搞懂
- TDD 的兩大學派:底特律 (Detroit) vs 倫敦 (London),你是哪一派?
- 單元測試和 TDD 之間的關係 (本篇)
- TDD、ATDD、BDD:三者到底差在哪?
- AI 時代,TDD 不是過時了,而是更重要了
- SDD — 當規格成為 AI 的導航系統
「我們團隊有寫單元測試啊,所以我們有在做 TDD」
如果你曾經聽過 (甚至說過) 這句話,這篇文章就是寫給你的
單元測試和 TDD 是軟體開發中最常被搞混的兩個概念。它們確實有關係,但絕對不是同一件事。搞清楚它們之間的差異,後面在團隊裡推動測試實踐的時候才不會走歪
先說結論
單元測試是一種「測試的層級」—— 你測什麼 TDD 是一種「開發的方法」—— 你怎麼開發 你可以寫單元測試但不做 TDD 你做 TDD 時會寫單元測試,但 TDD 不只是寫單元測試
它們的關係就像「跑步」和「晨跑習慣」——跑步是一個動作,晨跑習慣是一種生活方式。你可以偶爾跑步但沒有固定的晨跑習慣;你也可以養成晨跑習慣,但跑步只是其中的一環
什麼是單元測試
單元測試是針對軟體中最小可測試單元的自動化測試。說白了就是驗證一段程式碼是不是按照你預期的方式在跑
// calculator.js
export function add(a, b) {
return a + b
}
export function divide(a, b) {
if (b === 0) {
throw new Error('Cannot divide by zero')
}
return a / b
}
// calculator.test.js
import { describe, it, expect } from 'vitest'
import { add, divide } from './calculator.js'
describe('Calculator', () => {
it('should add two numbers', () => {
expect(add(2, 3)).toBe(5)
})
it('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5)
})
it('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero')
})
})
這就是一個標準的單元測試。注意,這裡完全沒有涉及任何開發流程或方法論。我們只是在驗證已經存在的程式碼是否正確。先寫程式碼,後寫測試,這是大多數團隊的做法,這叫做 Test-After Development,不是 TDD
單元測試的特徵
Michael Feathers 在 “Working Effectively with Legacy Code” (管理、修改、重構遺留程式碼的藝術) 中,用負面表列的方式定義了單元測試——如果你的測試有以下任何一項,那它就不是單元測試
- 連了資料庫
- 跨網路通訊
- 碰了檔案系統
- 不能與其他測試同時執行
- 需要特殊的環境設定 (例如修改設定檔)
而 Robert C. Martin 在 “Clean Code” (無瑕的程式碼) 中,則從正面角度提出了 F.I.R.S.T. 原則,描述好的單元測試應該具備的特徵
- Fast (快速):每個測試應該在毫秒內完成
- Isolated (隔離):不依賴外部資源,也不依賴其他測試的執行順序
- Repeatable (可重複):在任何環境下,每次執行結果都一致
- Self-Validating (自我驗證):測試自身判斷通過或失敗,不需要人工檢查
- Timely (及時):測試應該在適當的時機撰寫,最好在產品程式碼之前 (這也呼應了 TDD 的精神)
什麼是 TDD
TDD (Test-Driven Development,測試驅動開發) 是一種開發方法論。重點在於「用測試來驅動設計」,寫測試反而是附帶的
Kent Beck 在 2003 年出版的 “Test Driven Development: By Example” (Kent Beck 的測試驅動開發:案例導向的逐步解決之道) 中定義了 TDD 的核心循環
RED → 寫一個失敗的測試 ↓ GREEN → 寫最少的程式碼讓測試通過 ↓ REFACTOR → 重構,保持測試通過 ↓ 回到 RED
這三個步驟看起來很簡單,但背後有一個很不一樣的思維:你在寫任何產品程式碼之前,必須先有一個失敗的測試
用一個實際的例子來感受差異吧
同一個需求,三種做法
假設需求是:「開發一個密碼驗證器,密碼至少 8 個字元,必須包含大寫字母和數字」
底下我們會看到三種截然不同的開發方式。最終的測試程式碼可能長得很像,但背後的思維方式和開發節奏完全不同
做法一:先寫程式碼,再補測試 (Test-After)
這是大多數開發者的直覺做法——先實作功能,再補上測試
// 1 先寫好完整的產品程式碼
// password-validator.js
export class PasswordValidator {
validate(password) {
const errors = []
if (password.length < 8) {
errors.push('Password must be at least 8 characters')
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter')
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one digit')
}
return {
isValid: errors.length === 0,
errors,
}
}
}
// 2 然後再補上測試
// password-validator.test.js
import { describe, it, expect } from 'vitest'
import { PasswordValidator } from './password-validator.js'
describe('PasswordValidator', () => {
const validator = new PasswordValidator()
it('should accept valid password', () => {
const result = validator.validate('StrongPass1')
expect(result.isValid).toBe(true)
})
it('should reject short password', () => {
const result = validator.validate('Ab1')
expect(result.isValid).toBe(false)
})
it('should reject password without uppercase', () => {
const result = validator.validate('nouppercase1')
expect(result.isValid).toBe(false)
})
it('should reject password without digit', () => {
const result = validator.validate('NoDigitHere')
expect(result.isValid).toBe(false)
})
})
這些測試本身有價值嗎?有。它們能防止迴歸 (regression)。但這不是 TDD——測試沒有參與設計的過程,它們只是事後的驗證
做法二:先寫全部測試,再寫程式碼 (Test-All-First)
有些人聽到「TDD 是先寫測試再寫程式碼」之後,會這樣做
// 1 一口氣把所有測試都寫完
// password-validator.test.js
import { describe, it, expect } from 'vitest'
import { PasswordValidator } from './password-validator.js'
describe('PasswordValidator', () => {
const validator = new PasswordValidator()
it('should accept valid password', () => {
const result = validator.validate('StrongPass1')
expect(result.isValid).toBe(true)
expect(result.errors).toHaveLength(0)
})
it('should reject short password', () => {
const result = validator.validate('Ab1')
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Password must be at least 8 characters')
})
it('should reject password without uppercase letter', () => {
const result = validator.validate('lowercase1')
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Password must contain at least one uppercase letter')
})
it('should reject password without digit', () => {
const result = validator.validate('NoDigitHere')
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Password must contain at least one digit')
})
it('should return multiple errors for password violating multiple rules', () => {
const result = validator.validate('abc')
expect(result.isValid).toBe(false)
expect(result.errors).toHaveLength(3)
})
})
// 2 然後一口氣把所有產品程式碼寫完,讓測試全部通過
// password-validator.js
export class PasswordValidator {
validate(password) {
const errors = []
if (password.length < 8) {
errors.push('Password must be at least 8 characters')
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter')
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one digit')
}
return { isValid: errors.length === 0, errors }
}
}
備註:範例中的 toContain('精確字串') 是為了讓示範更直觀。實務上,用字串比對來斷言錯誤訊息會讓測試變得脆弱——只要文案微調,測試就會壞掉。建議改用 Error Code (例如 PASSWORD_TOO_SHORT) 或自訂的 Error Type 來判斷,測試會更穩定
這看起來「先寫測試再寫程式碼」了,但這仍然不是 TDD
為什麼?因為它違反了 TDD 最核心的節奏——一次只處理一個行為的變化
Test-All-First 的問題
Test-All-First 的流程
寫測試 A、B、C、D、E → 全部失敗 → 一口氣寫完產品程式碼 → 全過
問題
- 沒有回饋循環——你不知道「哪一段」程式碼讓「哪一個」測試通過
- 容易過度設計——你一開始就在腦中建構完整的解決方案
- 測試沒有驅動設計——你只是把「事後補測試」的順序反過來
- 無法享受 Refactor 階段——因為你沒有逐步建構的過程
- 失去「小步前進」的安全感——一次跳太大步
想像一下你一口氣寫完 5 個測試,然後開始寫產品程式碼。如果測試跑起來只通過了 3 個、失敗了 2 個,你知道問題出在哪嗎?你必須同時追蹤多個失敗的原因,debug 的範圍一下子就變大了
這就像考試時一次翻開所有題目然後同時作答——跟逐題解題比起來,心智負擔高了很多
做法三:TDD (一次一個測試,Red-Green-Refactor)
真正的 TDD 是每次只前進一小步
第一個循環:最簡單的情境
// ❌ RED - 寫一個失敗的測試
import { describe, it, expect } from 'vitest'
import { PasswordValidator } from './password-validator.js'
describe('PasswordValidator', () => {
it('should reject password shorter than 8 characters', () => {
const validator = new PasswordValidator()
const result = validator.validate('short')
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Password must be at least 8 characters')
})
})
此時 PasswordValidator 還不存在,測試當然會失敗。RED
// ✅ GREEN - 寫最少的程式碼讓測試通過
export class PasswordValidator {
validate(password) {
const errors = []
if (password.length < 8) {
errors.push('Password must be at least 8 characters')
}
return {
isValid: errors.length === 0,
errors,
}
}
}
測試通過了。GREEN。目前不需要重構,繼續下一個循環
第二個循環:加入大寫字母規則
// ❌ RED - 新增一個失敗的測試
it('should reject password without uppercase letter', () => {
const validator = new PasswordValidator()
const result = validator.validate('lowercase1')
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Password must contain at least one uppercase letter')
})
跑測試——第一個測試通過,第二個失敗。RED
// ✅ GREEN - 只加入讓新測試通過的程式碼
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter')
}
兩個測試都通過了。GREEN
第三個循環:加入數字規則
// ❌ RED
it('should reject password without digit', () => {
const validator = new PasswordValidator()
const result = validator.validate('NoDigitHere')
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Password must contain at least one digit')
})
// ✅ GREEN
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one digit')
}
第四個循環:正向測試
// ❌ RED (或直接 GREEN)
it('should accept password that meets all criteria', () => {
const validator = new PasswordValidator()
const result = validator.validate('ValidPass1')
expect(result.isValid).toBe(true)
expect(result.errors).toHaveLength(0)
})
這個測試直接通過了——因為之前的實作已經涵蓋了這個情境。在 TDD 中這是正常的:有時新測試會直接通過,代表你的設計已經自然涵蓋了這個行為
REFACTOR 階段:讓設計浮現
現在回頭看看,有沒有可以重構的地方
// 🔄 REFACTOR - 消除重複,讓設計更好
export class PasswordValidator {
#rules = [
{
test: (pw) => pw.length >= 8,
message: 'Password must be at least 8 characters',
},
{
test: (pw) => /[A-Z]/.test(pw),
message: 'Password must contain at least one uppercase letter',
},
{
test: (pw) => /[0-9]/.test(pw),
message: 'Password must contain at least one digit',
},
]
validate(password) {
const errors = this.#rules.filter((rule) => !rule.test(password)).map((rule) => rule.message)
return {
isValid: errors.length === 0,
errors,
}
}
}
跑一次所有測試——全部通過。REFACTOR 完成
重構後的設計比一開始「先想好整個架構再寫」的方式更乾淨、更容易擴展。這就是 TDD 厲害的地方——測試驅動出更好的設計
這次重構把每條規則抽成獨立的資料結構,未來要新增規則 (例如「必須包含特殊符號」) 時,只需要在 #rules 陣列裡加一筆,不用動到 validate 的主邏輯。這其實就是 OCP (Open-Closed Principle) 的精神——對擴展開放、對修改封閉
不過要特別提醒:TDD 的 Refactor 階段不一定要引入設計模式或抽象層。消除重複程式碼、改善命名、拆分過長的函式——這些都是有效的重構。不要為了「展現設計」而過度抽象,每次重構只做當下程式碼告訴你該做的事就好
三種做法的比較
Test-After:寫完所有產品程式碼 → 補寫所有測試
Test-All-First:寫完所有測試(全部失敗)→ 寫完所有產品程式碼
TDD:T1→C1→(Refactor) → T2→C2→(Refactor) → T3→C3→(Refactor) → T4→Refactor (T = 測試,C = 程式碼,括號內的 Refactor = 可選的小重構)
| 面向 | Test-After | Test-All-First | TDD |
|---|---|---|---|
| 測試的時機 | 產品程式碼之後 | 全部在產品程式碼之前 | 每個測試在對應的程式碼之前 |
| 回饋循環 | 無 (或很晚) | 只有一次大的回饋 | 持續的小回饋 |
| 步伐大小 | 一大步 | 一大步 | 很多小步 |
| 測試驅動設計 | 測試不影響設計 | 設計在寫測試前已決定 | 設計從測試中浮現 |
| 重構時機 | 事後 (如果有的話) | 事後 | 每個循環都有機會 |
| debug 範圍 | 整個功能 | 整個功能 | 只有最近新增的幾行 |
| 心智負擔 | 低到高 (bug 晚發現) | 高 (同時追蹤多個失敗) | 持續低 (一次只關注一件事) |
Test-All-First 和 TDD 的關鍵差異
很多人把 Test-All-First 誤認為是 TDD。它們看起來都是「先寫測試」,但本質上完全不同
Test-All-First 是「瀑布式的測試先行」——你在寫測試之前就已經在腦中想好了完整的設計,然後一口氣把它寫成測試。這等於把「設計→實作→測試」變成了「設計→測試→實作」,只是調換了後兩步的順序,設計這一步並沒有被測試所影響
TDD 是「演化式的測試驅動」——你不需要事先想好完整的設計。你只需要想到下一個最簡單的行為,寫一個測試,讓它通過,然後看看程式碼告訴你什麼。設計是在這個過程中逐步浮現的
用一個比喻來說
- Test-All-First 像是先畫好完整的設計圖,然後按圖施工
- TDD 像是有機建築——先打一根柱子,看看感覺如何,再決定下一根柱子要放在哪裡
Kent Beck 在 “Test Driven Development: By Example” (測試驅動開發:案例導向的逐步解決之道) 中描述了一個很有趣的觀點:TDD 的過程應該像是在跟程式碼對話。你問它一個問題 (寫一個測試),它回答你 (通過或失敗),然後你根據回答決定下一步。如果你一次問了十個問題,你就失去了這種對話的節奏
六個常見的誤解
誤解一:「有寫單元測試 = 有在做 TDD」
這是最普遍的誤解。再說一次
- 單元測試是測試的類型
- TDD 是開發的方法論
你可以在程式碼寫完三個月後補上完整的單元測試,覆蓋率達到 100%。但這不是 TDD。TDD 的關鍵在於時序——測試必須在產品程式碼之前寫
誤解二:「TDD 就是寫單元測試」
TDD 的核心是 Red-Green-Refactor 循環,不是寫單元測試。在 TDD 的過程中,你確實會產生大量的單元測試,但測試只是副產品,真正值錢的是
- 設計的回饋:測試難寫?說明你的設計有問題
- 小步前進:強迫你一次只解決一個問題
- 重構的信心:有測試保護,你敢大膽重構
- 活文件:測試本身就是最好的規格說明
Dan North (BDD 的創始人) 曾多次強調,TDD 本質上是設計方法,不是測試方法
誤解三:「先把測試全部寫完再寫程式碼就是 TDD」
前面已經比較過了,Test-All-First 和 TDD 看起來很像,骨子裡完全不同。TDD 最重要的是小步前進的回饋循環,不是「測試寫在前面」這個表面形式
一個簡單的判斷方式:如果你在寫測試的時候,心裡已經知道完整的實作方案,你多半不是在做 TDD,你只是在做 Test-First
TDD 的理想狀態是——你寫下一個測試時,並不完全確定最終的設計會長什麼樣子。你讓測試和重構的過程引導你走向好的設計
誤解四:「TDD 只用在單元測試」
Kent Beck 在多次演講中都有提到,TDD 並不限於單元測試。他在 “Test Driven Development: By Example” (測試驅動開發:案例導向的逐步解決之道) 書中,選擇用單元測試層級的範例來介紹 TDD,是因為單元測試回饋速度快、容易示範,但書中從來沒有說 TDD 只能用在單元測試層級
TDD 的重點是 Red-Green-Refactor 循環,這個循環跟測試的層級無關。不管是單元測試、整合測試還是驗收測試,只要能跑得動 Red-Green-Refactor,就能用 TDD 的方式來開發
實務上,London School 的 GOOS 書中提出的 Double Loop TDD (雙迴圈 TDD) 就是最好的示範
外部迴圈:驗收測試 (Acceptance Test)
內部迴圈:單元測試 (Unit Test) RED → GREEN → REFACTOR → RED → GREEN → …
外部迴圈的驗收測試從失敗開始, 經過多次內部迴圈的單元測試後,最終通過
外部迴圈寫的是一個端到端的驗收測試 (失敗的),然後透過多次內部迴圈的單元測試,一步步讓驗收測試通過
誤解五:「TDD 會讓開發變慢」
剛開始用 TDD 確實會比較慢,但跑了一陣子之後,你會發現省下來的時間其實更多
- 減少除錯時間 (bug 在產生的當下就被發現)
- 減少迴歸錯誤 (每次變更都有測試保護)
- 降低重構成本 (有信心做大規模重構)
- 程式碼即文件 (減少溝通成本)
Kent Beck 的名言:「我不是一個很好的程式設計師,我只是一個有著好習慣的程式設計師」
誤解六:「程式碼覆蓋率高 = 測試品質好」
100% 的覆蓋率不代表你的測試有意義。看看這個例子
// 有覆蓋率但沒有意義的測試
it('should create instance', () => {
const validator = new PasswordValidator()
expect(validator).toBeDefined() // 這個測試告訴我們什麼?
})
// 有意義的測試
it('should reject password with only lowercase letters', () => {
const validator = new PasswordValidator()
const result = validator.validate('alllowercase')
expect(result.isValid).toBe(false)
expect(result.errors).toContain('Password must contain at least one uppercase letter')
})
好的測試要能表達預期行為和邊界條件,光衝覆蓋率數字沒有用。TDD 寫出來的測試通常比較有意義,因為每個測試都是為了驅動出新的行為才寫的
它們之間的真正關係
軟體測試的世界
- 自動化測試
- 單元測試:TDD 產生的單元測試 + 事後補寫的單元測試
- 整合測試
- E2E 測試
- 開發方法論
- TDD / BDD / ATDD / Test-After (傳統開發)
簡單來說,單元測試是測試金字塔中的一個層級,回答的是「測什麼」。TDD 是一種開發方法論,回答的是「怎麼開發」。TDD 的過程會產生單元測試,但單元測試不一定來自 TDD。而且 TDD 也不限於單元測試,搭配整合測試甚至驗收測試 (如 ATDD、BDD) 都行
根據情境,決定從哪裡開始
走到這裡,你可能會問:「所以我到底該用 TDD 還是寫單元測試?」但這其實不是二擇一的問題。單元測試是測試金字塔裡的一個層級,TDD 是一種開發方法論——兩者是不同維度的東西,沒有誰取代誰的問題
該問的是:以目前的狀況,先從哪裡開始、先做到什麼程度
不過話說回來,雖然兩者不是二擇一,但確實有先後順序。單元測試是 TDD 的基礎,連單元測試都還寫不順手就直接跳去做 TDD,那就像還沒學走路就想跑一樣。先把單元測試寫好、寫熟,後面要跑 TDD 的循環才會順暢
情境一:遺留系統,完全沒有測試
先從補寫單元測試開始。不要急著導入 TDD。先建立安全網,理解現有程式碼的行為,再逐步引入 TDD 到新功能的開發中
Michael Feathers 在 “Working Effectively with Legacy Code” (管理、修改、重構遺留程式碼的藝術) 中提出的方法是先寫「特徵測試」(Characterization Test)——不是測試程式碼「應該」做什麼,而是測試程式碼「實際」做了什麼
// 特徵測試 - 記錄現有行為
it('should return -1 when user not found (characterization test)', () => {
// 我們不確定這是不是「正確」的行為
// 但這是現在程式碼實際的行為
const result = legacyUserService.findUser('nonexistent')
expect(result).toBe(-1)
})
情境二:全新專案,團隊有經驗
直接採用 TDD。從第一行程式碼開始就用 TDD 的方式開發。沒有歷史包袱,團隊也有足夠的測試經驗,這時候用 TDD 最能發揮效果
拿到需求之後,先拆解成幾個小的行為場景,然後針對第一個場景寫下第一個失敗的測試。這個測試不用太複雜,可能就是最基本的輸入輸出驗證。看到紅燈之後,用最簡單的方式讓它通過,接著再重構。每一輪 Red-Green-Refactor 的循環控制在幾分鐘之內,維持節奏感
團隊層面的話,可以在初期搭配 Pair Programming 來對齊 TDD 的節奏和粒度。有些團隊會先花一兩個 Sprint 專門用 TDD 來開發核心模組,建立信心和共識之後,再把這個習慣擴展到整個專案。重點是讓每個人都經歷過完整的 TDD 循環,而不只是聽過理論
情境三:團隊對測試還不熟悉
先學會寫好的單元測試。理解什麼是好的測試、什麼是壞的測試,掌握測試替身的使用,然後再進階到 TDD。TDD 需要一定的測試技能作為基礎
- 理解測試的價值
- 會寫單元測試
- 會寫好的單元測試(可讀、可維護、有意義)
- 理解並使用 Test Double
- 開始練習 TDD
- TDD 成為自然的開發方式
- 能在不同情境靈活運用 TDD 學派
TDD 改變了什麼
最後用蓋房子來比喻
傳統開發就像是先蓋房子,蓋完之後再請驗屋師來檢查。如果發現結構有問題,修起來代價很大
Test-All-First 就像是先讓驗屋師列出所有驗收項目的清單,然後蓋好整棟房子後一次驗收。清單是有了,但蓋的過程中沒有人幫你把關
TDD 就像是先請驗屋師定義好每個階段的驗收標準,然後每砌一面牆就檢查一次。房子蓋好之後品質好,是因為整個建造過程本身就是在品質驅動下完成的
Kent Beck 曾說:「JUnit 不只是一個技術工具,它是一個政治宣言。作為程式設計師,我承擔我工作品質的完全責任」
回到開頭的比喻——跑步是一個動作,晨跑習慣是一種生活方式。單元測試就是那個跑步的動作,它給你安全網;TDD 是那個晨跑習慣,它給你設計方法。光會跑步不代表你有運動習慣,但養成晨跑習慣的人,跑步能力一定不會差。兩個都學會了,寫出來的東西自然不會差
圖片來源:AI 產生