Logo
Published on

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

TDD 兩大學派示意圖:底特律學派 vs 倫敦學派

測試系列文章

  • Test Double 測試替身入門:五種類型一次搞懂
  • TDD 的兩大學派:底特律 (Detroit) vs 倫敦 (London),你是哪一派? (本篇)
  • 單元測試和 TDD 之間的關係
  • TDD、ATDD、BDD:三者到底差在哪?
  • AI 時代,TDD 不是過時了,而是更重要了
  • SDD — 當規格成為 AI 的導航系統

當你開始認真研究 TDD,遲早會碰到一個問題:同樣都在寫測試,為什麼有人堅持使用真實物件,有人卻到處都在 mock?其實 TDD 發展到現在,已經分出了兩條路線,Detroit SchoolLondon 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 SchoolLondon School
別名Classicist、Chicago、Inside-OutMockist、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:使用者資料,有 nameisVip 屬性
  • 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 外部依賴 (例如通知服務)

問題是,這時候 UserGreetingService 都還沒寫出來,所以這個測試一定是紅燈的。沒關係,先放著。這個紅燈就像北極星,告訴你整個功能完成的終點在哪裡

// 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 只是一個帶有 nameisVip 屬性的簡單物件,不是透過 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 的世界,「走起來像鴨子、叫起來像鴨子,那就是鴨子」。只要你傳進去的物件有 nameisVip 這兩個屬性,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')
  })
})

第四步:驗收測試變綠了

現在 GreetingServiceUser 都實作完畢,回頭跑一開始那個紅燈的驗收測試,不需要改任何東西,它已經自動變綠了。整個功能從使用者的角度來看是通的

這就是 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 產生