- Published on
TDD 的兩大學派:底特律 (Detroit) vs 倫敦 (London),你是哪一派?

測試系列文章
- Test Double 測試替身入門:五種類型一次搞懂
- TDD 的兩大學派:底特律 (Detroit) vs 倫敦 (London),你是哪一派? (本篇)
- 單元測試和 TDD 之間的關係
- TDD、ATDD、BDD:三者到底差在哪?
- AI 時代,TDD 不是過時了,而是更重要了
- SDD — 當規格成為 AI 的導航系統
當你開始認真研究 TDD,遲早會碰到一個問題:同樣都在寫測試,為什麼有人堅持使用真實物件,有人卻到處都在 mock?其實 TDD 發展到現在,已經分出了兩條路線,Detroit School 和 London School
這篇文章會帶你認識這兩大學派的起源和核心差異,也透過 JavaScript 範例讓你親身體會兩種風格到底差在哪
底特律學派的起源:從 Kent Beck 說起
1996 年,Kent Beck 被聘請參與 Chrysler 的 C3 (Chrysler Comprehensive Compensation) 薪資系統專案。這個專案當時已經陷入困境,用傳統的瀑布式開發進行了好幾年卻遲遲無法交付。Kent Beck 加入後,帶進了一套全新的工作方式:每次只寫一小段測試,讓測試失敗,接著寫出剛好能通過測試的程式碼,然後重構。這套「紅、綠、重構」的節奏,就是我們今天熟悉的 TDD
C3 專案不只催生了 TDD,也成了 Extreme Programming (XP) 的搖籃。而 Chrysler 的總部就在密西根州的底特律 (Detroit) 附近,這也是為什麼後人把 Kent Beck 這一脈的 TDD 風格稱為 Detroit School
1997 年,Kent Beck 與 Erich Gamma 在飛往 OOPSLA 會議的航班上,用 pair programming 的方式寫出了 JUnit 的第一個版本。他們是用 TDD 的方式來開發這個測試框架的,用測試驅動開發來開發測試框架,想想還滿妙的
Kent Beck 曾說:「JUnit 不只是一個技術工具,它是一個政治宣言。作為程式設計師,我承擔我工作品質的完全責任」
C3 專案的經驗直接影響了 Detroit School 的做法:從最核心的領域物件開始,用真實的物件來組裝,然後看狀態對不對。想想也合理,薪資系統嘛,你不太會去管中間呼叫了哪些方法,你只在乎最後薪水算出來的數字是不是對的
這就是 Detroit School (也稱為 Chicago School 或 Classicist) 的起源
中文常稱為底特律學派、芝加哥學派或古典學派
倫敦學派的興起
2000 年代初期,在倫敦有個名為 Extreme Tuesday Club 的技術社群聚會。每週二晚上,一群對 XP 和敏捷開發充滿熱情的開發者會聚在一起,討論如何把這些實踐做得更好。Steve Freeman 和 Nat Pryce 就是這個社群的核心成員
Freeman 和 Pryce 在實踐 TDD 的過程中,開始思考一個問題:當系統變得複雜,物件之間的協作關係才是真正困難的地方。一個物件做的事情可能很簡單,但十個物件串在一起運作時,問題往往出在它們之間的溝通方式。如果測試總是把所有真實物件放在一起跑,當測試失敗的時候,你很難一眼看出到底是哪個環節出了問題
他們的想法是:與其測試一整串物件組合在一起的最終狀態,不如把每個物件隔離開來,專門看它和協作者之間的互動對不對。Mock 在這裡的用途也跟著變了,它不再只是用來「替代」外部依賴的替身,更像是拿來「定義」物件之間該怎麼溝通的工具
2009 年,Freeman 和 Pryce 出版了 “Growing Object-Oriented Software, Guided by Tests” (簡稱 GOOS),這本書成了 London School 的聖經。書裡面有個觀念我覺得很精彩:寫測試和用 mock 的過程中,你會「發現」物件之間需要什麼介面。換句話說,測試同時也是設計的手段。你先從外層寫測試,假設內層的協作者已經存在,用 mock 定義它們應該提供什麼方法、接收什麼參數。測試寫完之後,這些 mock 的介面自然就變成你下一步要實作的東西
這種從外往內推進的方式,讓你一邊寫測試一邊做設計,每個物件該負責什麼事情,寫著寫著就出來了
這就是 London School (也稱為 Mockist)
中文常稱為倫敦學派或模擬學派
兩大學派的核心差異
先用一張表格快速比較
| 面向 | Detroit School | London School |
|---|---|---|
| 別名 | Classicist、Chicago、Inside-Out | Mockist、Outside-In |
| 開發方向 | 從核心領域開始,向外擴展 | 從 API/UI 開始,向內實作 |
| 驗證方式 | 狀態驗證 (State) | 行為驗證 (Behavior) |
| Mock 使用 | 最小化,只用於外部依賴 | 廣泛使用 |
| 單元定義 | Sociable Unit Tests (可跨多個類別) | Solitary Unit Tests (嚴格隔離單一類別) |
表格看完了,但幾個概念可能還是有點模糊。底下拆開來聊聊
狀態驗證 vs 行為驗證
狀態驗證就像去餐廳點餐,你只管最後送上來的菜對不對。廚師是用炒的還是用蒸的,中間換了幾次鍋子,你都不在意,盤子裡的東西對了就好
行為驗證比較像你站在廚房門口盯著看。廚師有沒有先洗菜、有沒有照食譜的順序加調味料、有沒有在對的時間把菜送進烤箱,你關心的是每個步驟有沒有做對
用在程式碼上,Detroit School 的測試會去檢查物件的屬性值或回傳結果,例如「購物車的總金額是不是 500 元」。London School 的測試則會去檢查物件有沒有正確地呼叫其他物件的方法,例如「結帳服務有沒有把正確的金額傳給金流服務」
Inside-Out vs Outside-In
Inside-Out (Detroit) 像在蓋房子。先打地基,再蓋牆壁,最後裝屋頂。你從系統最核心、最沒有依賴的部分開始做起,一層一層往外疊。每一步都踩在已經測試過的地基上,很踏實。不過風險是到最後才發現外層的需求跟你想的不一樣
Outside-In (London) 比較像做室內設計。先看整體空間要怎麼用,再決定每個房間的配置,最後才拉水電管線。你從使用者看到的那一層開始,逐步往系統內部推進。開發方向會一直跟使用者需求對齊,但代價是一開始要用很多假的東西 (mock) 來撐場面
Mock 使用哲學
Detroit School 把 mock 當成「不得已的替代品」。只有在碰到真的無法在測試中使用的東西時才會用,例如外部 API、資料庫連線、寄送 email 這類帶有副作用的外部服務。能用真實物件就用真實物件,因為這樣測試才能反映系統真正運作的方式
London School 把 mock 當成「設計工具」。每個被測試物件的協作者都用 mock 來代替。這樣做的好處是每個測試只關注一個物件,出問題時馬上知道是誰的錯。而且在定義 mock 介面的過程中,你其實已經在設計協作者應該長什麼樣子了
重構耐受度
這是選擇學派時一個很重要的 Trade-off 考量
Detroit School 做的是狀態驗證,所以重構耐受度很高。你今天把內部的實作整個翻掉都沒關係,只要最終結果沒變,測試就不會壞。想改演算法?想把三個方法合成一個?儘管改,測試照樣綠。不過反過來說,如果內部的互動方式出了問題,例如某個協作者被用錯誤的方式呼叫了,但剛好結果還是對的,狀態驗證有時候不容易抓到這種問題
London School 做的是行為驗證,重構耐受度就低很多。即使功能完全沒變,只要你動了內部實作,比如改了方法名稱、換了呼叫順序、把一個方法拆成兩個,那些用 toHaveBeenCalledWith 寫的斷言就很可能跟著壞掉。你得回頭一個一個修測試,即使系統行為根本沒有改變。這也是 Mockist 風格最常被提到的缺點,測試太容易因為重構而失敗,維護成本會隨著專案規模快速上升
簡單來說,Detroit 的測試比較不怕你改程式碼,London 的測試比較怕你改結構。選擇的時候要想清楚你的系統處在什麼階段,如果還在快速迭代、介面經常變動,過多的行為驗證可能會讓你花太多時間在修測試上
如果你對 Mock、Stub、Spy 這些測試替身的差異還不太熟悉,可以先看看 Test Double 測試替身入門:五種類型一次搞懂
聽起來很抽象?我們用程式碼來說話
實戰:使用者問候系統
我們用一個極簡的「使用者問候系統」來比較兩個學派的做法。需求很單純
- 可以建立使用者,使用者有名字和 VIP 狀態
- VIP 使用者會得到特殊的問候訊息
- 問候訊息產生後,要透過通知服務發送出去
這裡只會有兩個類別加一個外部依賴
User:使用者資料,有name和isVip屬性GreetingService:根據使用者產生問候訊息,並透過NotificationSender發送NotificationSender:外部通知服務 (兩個學派都會 mock 它)
測試框架使用 Vitest,接下來看看兩個學派如何實作
Detroit School 風格
Detroit School 的做法可以用一句話概括:從內而外,使用真實物件,驗證狀態
第一步:從最核心的 User 開始
Detroit School 先從系統最內層、最沒有依賴的物件做起
// user.test.js
import { describe, it, expect } from 'vitest'
import { User } from './user.js'
describe('User', () => {
it('should create a regular user', () => {
const user = new User('小明', false)
expect(user.name).toBe('小明')
expect(user.isVip).toBe(false)
})
it('should create a VIP user', () => {
const user = new User('大戶王先生', true)
expect(user.name).toBe('大戶王先生')
expect(user.isVip).toBe(true)
})
it('should not allow empty name', () => {
expect(() => new User('', false)).toThrow('Name is required')
})
})
測試都寫好了,接著寫出剛好通過測試的實作
// user.js
export class User {
constructor(name, isVip = false) {
if (!name) {
throw new Error('Name is required')
}
this.name = name
this.isVip = isVip
}
}
第二步:建立 GreetingService (使用真實 User + mock 外部依賴)
核心物件測試通過後,往外推進一層。注意這裡使用真實的 User 物件,只有外部的 NotificationSender 才用 mock
// greeting-service.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { GreetingService } from './greeting-service.js'
import { User } from './user.js'
describe('GreetingService', () => {
let mockNotificationSender
let service
beforeEach(() => {
// 只 mock 外部依賴
mockNotificationSender = { send: vi.fn() }
service = new GreetingService(mockNotificationSender)
})
it('should greet regular user with standard message', () => {
// Arrange - 使用真實的 User
const user = new User('小明', false)
// Act
const message = service.greet(user)
// Assert - 驗證回傳的狀態 (訊息內容)
expect(message).toBe('嗨,小明!歡迎回來')
})
it('should greet VIP user with special message', () => {
const user = new User('大戶王先生', true)
const message = service.greet(user)
expect(message).toBe('尊貴的 大戶王先生,歡迎回來!感謝您的長期支持')
})
it('should send greeting via notification sender', () => {
const user = new User('小明', false)
service.greet(user)
// 也驗證外部服務有被正確呼叫
expect(mockNotificationSender.send).toHaveBeenCalledWith('小明', '嗨,小明!歡迎回來')
})
})
// greeting-service.js
export class GreetingService {
constructor(notificationSender) {
this.notificationSender = notificationSender
}
greet(user) {
const message = user.isVip
? `尊貴的 ${user.name},歡迎回來!感謝您的長期支持`
: `嗨,${user.name}!歡迎回來`
this.notificationSender.send(user.name, message)
return message
}
}
Detroit School 小結
回頭看看 Detroit School 的做法,有幾個特徵很明顯
開發順序是從核心往外走:先測試 User,再測試 GreetingService。測試中盡量使用真實物件,GreetingService 的測試就直接用真實的 User,不用 mock。斷言主要在檢查回傳值和物件的狀態,例如 message 的內容是不是正確的字串。只有碰到 NotificationSender 這種外部服務,才會用 mock 來替代
London School 風格
London School 走的是完全相反的路:從外而內,隔離所有協作者,驗證行為互動
第一步:先寫一個失敗的驗收測試
London School 的 Outside-In 開發有個招牌動作,來自 GOOS 書中的核心概念:開發任何功能之前,先從使用者的角度寫一個端到端的驗收測試。這個測試會用真實物件串接整個流程,只 mock 外部依賴 (例如通知服務)
問題是,這時候 User 和 GreetingService 都還沒寫出來,所以這個測試一定是紅燈的。沒關係,先放著。這個紅燈就像北極星,告訴你整個功能完成的終點在哪裡
// greeting.acceptance.test.js
import { describe, it, expect, vi } from 'vitest'
import { User } from './user.js'
import { GreetingService } from './greeting-service.js'
describe('Greeting System (Acceptance Test)', () => {
it('should greet VIP user and send notification', () => {
// 只 mock 外部依賴,其他都用真實物件
const mockNotificationSender = { send: vi.fn() }
const user = new User('大戶王先生', true)
const service = new GreetingService(mockNotificationSender)
const message = service.greet(user)
expect(message).toBe('尊貴的 大戶王先生,歡迎回來!感謝您的長期支持')
expect(mockNotificationSender.send).toHaveBeenCalledWith(
'大戶王先生',
'尊貴的 大戶王先生,歡迎回來!感謝您的長期支持'
)
})
})
跑測試,紅燈。完全預期中的事。接下來進入內層迴圈,用 mock 隔離每個元件,逐一寫單元測試和實作
第二步:從最外層的 GreetingService 開始
London School 從使用者的角度出發,先處理最外層的入口。所有的協作者一律用 mock 替代,包括 User
// greeting-service.test.js (London Style)
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { GreetingService } from './greeting-service.js'
describe('GreetingService (London Style)', () => {
let mockNotificationSender
let service
beforeEach(() => {
mockNotificationSender = { send: vi.fn() }
service = new GreetingService(mockNotificationSender)
})
it('should send standard greeting for regular user', () => {
// Arrange - User 用簡單物件代替,不依賴真實的 User 類別
const stubUser = { name: '小明', isVip: false }
// Act
service.greet(stubUser)
// Assert - 驗證行為:NotificationSender 有沒有被正確呼叫
expect(mockNotificationSender.send).toHaveBeenCalledWith('小明', '嗨,小明!歡迎回來')
})
it('should send VIP greeting for VIP user', () => {
const stubUser = { name: '大戶王先生', isVip: true }
service.greet(stubUser)
expect(mockNotificationSender.send).toHaveBeenCalledWith(
'大戶王先生',
'尊貴的 大戶王先生,歡迎回來!感謝您的長期支持'
)
})
it('should return the greeting message', () => {
const stubUser = { name: '小明', isVip: false }
const message = service.greet(stubUser)
expect(message).toBe('嗨,小明!歡迎回來')
})
})
注意看,這裡的 stubUser 只是一個帶有 name 和 isVip 屬性的簡單物件,不是透過 new User() 建立的。為什麼叫 Stub 而不叫 Mock?因為它只是提供固定資料讓 GreetingService 讀取,並不會驗證任何互動。真正扮演 Mock 角色的是 mockNotificationSender,我們會用 toHaveBeenCalledWith 去檢查它有沒有被正確呼叫。如果你對這兩者的區別還有點模糊,可以回頭看看 Test Double 測試替身入門 裡面的分類
London School 只關心 GreetingService 跟協作者之間的互動,不在乎協作者內部怎麼實作。而且當你在定義 stubUser 的結構時,其實就已經在設計 User 物件的介面了,這正是 Outside-In 驅動設計的具體展現
順帶一提,在 Java 或 C# 這類強型別語言裡,你通常會用 Mocking Framework (例如 Mockito、Moq) 來根據介面建立測試替身。但 JavaScript 是 Duck Typing 的世界,「走起來像鴨子、叫起來像鴨子,那就是鴨子」。只要你傳進去的物件有 name 和 isVip 這兩個屬性,GreetingService 根本不在意它是不是真正的 User 實例。所以在 JavaScript 裡,直接丟一個符合介面形狀的普通物件 (Stub) 進去就能達到隔離的效果,這也完全符合 London School 把每個協作者都替換掉的精神
第三步:測試 User (隔離測試)
外層搞定後,才往內層推進
// user.test.js (London Style)
import { describe, it, expect } from 'vitest'
import { User } from './user.js'
describe('User (London Style)', () => {
it('should expose name and non-VIP status', () => {
const user = new User('小明', false)
expect(user.name).toBe('小明')
expect(user.isVip).toBe(false)
})
it('should expose VIP status', () => {
const user = new User('大戶王先生', true)
expect(user.isVip).toBe(true)
})
it('should not allow empty name', () => {
expect(() => new User('', false)).toThrow('Name is required')
})
})
第四步:驗收測試變綠了
現在 GreetingService 和 User 都實作完畢,回頭跑一開始那個紅燈的驗收測試,不需要改任何東西,它已經自動變綠了。整個功能從使用者的角度來看是通的
這就是 Double Loop 的完整循環:外層迴圈是驗收測試從紅到綠,中間靠內層的單元測試一步步把每個元件做出來。外層告訴你「還沒到終點」,內層告訴你「下一步做什麼」
London School 小結
London School 的特徵跟 Detroit 剛好相反
開發順序從外層往內走:先測試 GreetingService,最後才輪到 User。每個測試都只測試一個類別,協作者一律用 Stub 或簡單物件隔離。斷言大量使用 toHaveBeenCalledWith 來驗證物件之間的互動。還有一個有趣的副作用:寫 GreetingService 測試的時候,你透過定義 stubUser 的形狀,其實已經在「設計」User 該有什麼屬性了
深入理解:雙重迴圈 (Double Loop TDD)
剛才的範例就是 GOOS 書中所說的 Double Loop TDD 的完整示範。外層迴圈是驗收測試,從一開始的紅燈撐到最後變綠。內層迴圈就是每個元件各自的 Red-Green-Refactor,一個一個做完
每一輪的 mock 就是下一輪的待辦清單。你在外層「假設」了協作者該有什麼能力,等外層搞定後,就往內層推進去兌現這些假設。一層做完換下一層,直到所有 mock 都被真實的實作取代、驗收測試亮綠燈為止
這個雙迴圈的節奏,正是 London School 最強的設計驅動力。它讓你在寫測試的同時,自然而然地把系統的職責分配和介面設計都想清楚了
兩種風格的開發流程比較
Detroit School (Inside-Out)
User GreetingService
(核心) ---> (整合外部)
真實物件 真實 User + Mock NotificationSender
London School (Outside-In)
GreetingService User
(入口點) ---> (實作)
Mock User + Mock 隔離測試
NotificationSender
用蓋房子來比喻的話,Detroit School 是從地基往上蓋,先把基礎結構做好再一層一層往上疊。London School 是從外觀設計圖開始,先決定房子長什麼樣,再往內部填充結構
實務上怎麼用
前面花了不少篇幅介紹兩個學派,主要是讓你了解 TDD 的起源和目前主流的兩種做法。不過實務上你不太需要「選邊站」,因為兩者各有擅長的場景,碰到範圍比較大的 TDD,例如一支 API 要做端到端的測試驅動開發時,通常會兩種混著用,而不是只挑其中一個
具體的混用流程大概長這樣
- 驗收測試釘需求:一開始先寫一個失敗的驗收測試,從使用者的角度描述這支 API 預期的行為,讓需求有一個明確的完成定義
- 核心邏輯用 Detroit School:算價、驗證規則、狀態轉換這類純邏輯的部分,直接用真實物件跑起來驗證狀態,不靠任何 mock
- 協作串接用 London School:當元件之間需要互相呼叫,例如 Service 呼叫 Repository、Controller 呼叫 Service,就切到 London School,用 mock 把彼此的介面定清楚
- 外部依賴一律 mock:資料庫、第三方 API、檔案系統這些不可控的外部資源,不管你用哪個學派,都應該用 Test Double 隔離掉
這樣混用其實有道理。Detroit School 的狀態驗證讓核心邏輯測試不怕重構,只要結果對了,內部怎麼改都不會讓測試壞掉。London School 的行為驗證則幫你把元件之間的協作介面定義清楚,開發初期就能抓到串接問題。核心穩、介面清楚,兩者搭配剛好互補
如果你是剛開始接觸 TDD 的新手,建議先從 Detroit School 入手。原因很簡單:它用真實物件搭配狀態驗證,概念比較直覺,不需要先搞懂 Mock 和 Stub 的差別就能上手。而且 Detroit 風格的測試比較耐重構,你改了內部實作不容易壞測試,學習的挫折感會少很多。等你對 TDD 的節奏夠熟悉了,再來嘗試 London School 的隔離測試和行為驗證,理解上會順暢很多
不管你偏好 Detroit 還是 London,先開始寫測試就對了。寫多了自然會找到適合自己專案的混用比例
「If you’re doing something different than the following workflow & it works for you, congratulations!」 — Kent Beck, Canon TDD
圖片來源:AI 產生