TL;DR

測試驅動開發(TDD)的概念與本質深入剖析

大家好,我是在 ONDA 開發飯店管理解決方案的後端工程師 Gunner(정재훈)。

上一篇文章中,我們從不同角度檢視了軟體測試的種類,也提到了 BDD 和 TDD 這類測試工具。

有時候 TDD 和測試會被混為一談,導致概念模糊。接下來我會循序漸進地說明 TDD 的概念、開發者之間爭論的核心,以及對開發者來說什麼才是好測試。

1. 什麼是測試驅動開發(Test-Driven Development, TDD)

首先來看 TDD 的概念。TDD 是 Kent Beck 在 《Test-Driven Development By Example》(以下簡稱 TDDBE) 一書中提出的軟體設計技巧,透過測試來驅動開發。

*很多人把 TDD 當成測試方法,但我個人認為它更接近設計方法。

TDDBE 主張的 TDD 做法很簡單:

1. 先寫一個會失敗的測試 2. 用最快的方式寫出能通過測試的程式碼 3. 重構

書中把這個流程稱為 「Red-Green-Refactor 循環」 —— 取自測試失敗時的紅色與成功時的綠色。

Smalltalk SUnit 的 TestRunner 結果
Smalltalk SUnit 的 TestRunner 結果

TDDBE 裡開發 Money 類別時實作了貨幣單位等功能。比如先以美元($)為基準開發讓測試通過,但套用其他貨幣單位時會失敗,於是再建立新類別繼續開發下去。

先寫失敗的測試,展示 Red-Green-Refactor 循環;用 Test-First 方式建立的自動化測試,不只驅動(drive) Money 類別的設計方向,新功能加入時也能檢查既有功能是否正常運作,扮演安全網的角色

另一個值得注意的是 TDDBE 裡的步伐非常小。Kent 後來在訪談中表示:「並不是一定要小步前進,而是當想不出好的設計時可以小步探索,一旦有把握就能加快步伐 —— 目的是培養這種感覺。」

總結來說,TDDBE 的 TDD 核心就是 Red-Green-Refactor 循環,透過它做出好設計與自動化測試。

Test-Driven Development by Kent Beck, 2002 年 11 月
Test-Driven Development by Kent Beck, 2002 年 11 月

2. TDD 在開發者之間引發爭論

TDD 出現後,開發者之間論戰不斷。主要分成「應該用 TDD 開發」與「TDD 不適合實務」兩派。

「應該用 TDD 開發」 的人主張 TDD 帶來的好處 —— Test-First 帶來好設計,開發過程中建立自動化測試等;但 「TDD 不適合實務」 的人則認為 Test-First 的設計方式不能認同。

(1) Kent 為什麼主張 TDD

在詳細討論之前,先來看 Kent 主張 TDD 的背景。

Kent 主要使用的語言是 Smalltalk,他用這個語言進行知名的 C3 專案,也導入了 XP(eXtreme Programming)。Smalltalk 在 VM(Virtual Machine)上執行,開發環境本身就是一個 IDE(Integrated Development Environment),原始碼儲存為單一整合映像檔。

GemStone Smalltalk 平台
GemStone Smalltalk 平台

Smalltalk 是物件導向語言,具備以下適合導入 TDD 的環境與條件:

  • 可以在程式碼瀏覽器裡直接執行程式碼
  • 設計成由大量小物件互相傳訊息來運作
  • Smalltalk 是 MVC 模式的始祖,但設計 MVC 時不像現在的 Java 專案用分層概念區分檔案,而是將整個原始碼儲存為單一映像檔
  • xUnit 的始祖是 SUnit

但一般 Java Spring 專案不是這樣:

  • 執行程式碼需要啟動 Spring context
  • 相較於 Smalltalk,類別的拆分相對較少
  • 受到 DDD(Domain-Driven Design)、六角形架構等影響,分層明確,每層都用獨立檔案。這可以視為測試範圍的擴大
  • JUnit 是從 SUnit 移植過來的

Smalltalk 不像 Java 那樣「檔案 - 類別」的尋找方式,物件之間互動多,需要用自動化測試來確認。換個角度說,Smalltalk 能快速執行單元測試並立即得到回饋,是非常適合開發的環境。

(2) 「TDD 已死,測試長存」

2000 年代初 TDD 概念出現後,開發者之間討論熱烈,如前述分成積極接受與否定兩派。2014 年,Ruby on Rails(*全端網頁框架,影響了後來幾乎所有全端網頁框架)的作者 DHH(David Heinemeier Hansson)在部落格發表了標題挑釁的文章 「TDD 已死,測試長存」,再次掀起 TDD 論戰。

DHH 主張的重點如下:

  • TDD 有其效用,但已變成教條式的宗教
  • TDD 過度聚焦單元測試,有時帶來不必要的複雜性(例如 mock)
    • 分層之間的中介物件交織成複雜結構
    • 測試中避免直接使用 DB 或 I/O → 系統測試被視為壞事
    • 服務物件、命令模式等更糟的東西交織成叢林 → 只為測試而濫用模式與物件設計
  • TDD 或許能驅動設計,但與我的設計方式不合
  • 我幾乎不做傳統意義上的單元測試 —— 把所有依賴都做成 mock,數千個測試幾秒內跑完
    • Rails 不認為這是好測試
    • 我直接測試 ActiveRecord 模型,建立 fixture 直接存取 DB
    • 頂層有控制器測試,但我偏好用 Capybara 這類工具做高階系統測試來取代
  • 不是說大家都要跟我一樣,但「一定要做 TDD 才是好開發」的風氣該停了

(3) Kent Beck vs. DHH 對 TDD 的看法

這篇文章引起支持 TDD 陣營的強烈反彈,在網路上掀起論戰。

爭論持續一段時間後,最終由 Martin Fowler(*以《Refactoring》聞名的軟體開發者)主持了 Kent Beck 與 DHH 的線上對談,當時討論的內容整理如下:

TDD 說的單元測試定義是什麼

  • DHH: TDD 裡單元測試的定義是什麼?一定要用 mock 做單元測試嗎?而且 Red-Green-Refactor 循環對我來說有些適合有些不適合。

  • Kent: 程式設計師測試程式本來就理所當然(用什麼方式都行)。我用 Smalltalk 寫程式時把測試自動化了,也嘗試 Test-First,對我來說非常合適。

  • 共識: TDD 有其效用,但依個人喜好或專案特性可能有不同。

  • DHH: 很多人大量使用 mock 做了糟糕的取捨(Trade-off)。為什麼要用 mock 到這種程度吃虧?

  • Kent: 使用 mock 是一種取捨,這點我同意。如果用真實物件就能建立快速回饋循環,我會用真實物件。我做 TDD 時幾乎沒用過 mock(Smalltalk 不像 Java 分層,不用 mock 就能直接測試)。

    另外寫測試後修改實際程式碼時,測試程式碼也要跟著改;實際程式碼變動多的話,對應的 mock 也全都要改。測試讓重構更容易,但 mock 讓重構更困難,這點我有點擔心。

  • DHH: 用大量 mock 的分層架構間接參照太多,複雜度過高。

  • Kent: 這不是 TDD 造成的問題。

  • Martin: 沒錯,這源自六角形(Hexagonal)架構的核心「與環境徹底分離」。

  • DHH: 同意。但我常看到有人把分離本身當目的強調,因為 TDD 要做單元測試。

    採用六角形架構的人說這個(領域模型)可以當終端應用程式或指令列工具用。我想問他們:真的會有這種情況嗎?

    Repository 也一樣。他們說可以把存取 DB 的實作改成記憶體 DB 或 web service,但真的會發生嗎?

  • Kent: 這也不是 TDD 的問題,而是設計問題或能多快得到回饋的問題。設計問題就是要好好考慮設計。

  • 共識: 快速回饋很重要,TDD 無法取代 QA。

所有程式碼都該做 TDD 嗎?測試會不會太多或不必要?

  • Kent: 我也不是總是做 TDD。人們寫測試不是為了付出成本,而是為了獲得足夠的信心。

  • Martin: 過度測試確實存在。如果我把這行程式碼註解掉,測試會不會壞?重點是把會壞的情況寫成測試。getter 這種可以信任,不必測試。

    • 無法有信心地修改程式碼? → 測試不夠或不是好測試的訊號
    • 修改程式碼時改測試更困難? → 測試太多的訊號

TDD 在哪些方面有用?

  • Kent: TDD 把問題拆成小單位,給予信心。
  • Martin: 有適合 TDD 的領域,也有不適合的領域。
  • DHH: 同意有適合的領域。以我個人經驗,MVC 網頁應用常常不適合。但即使在不適合 TDD 的領域,自動化的自我測試也絕對必要。我認為那才是 TDD 的重要價值。

結論是 TDD 是好工具,但某種程度上是喜好問題,也不是適合所有領域的工具。

💡關於 TDD 是喜好問題,我們也來看看 Clojure 作者 Rich Hickey 的說法。

Rich 稱之為 「護欄式開發」,批評 TDD 的 Red-Green-Refactor 循環說:「因為有測試所以能修改 —— 誰這樣做?誰開車會撞護欄開的?」他批評的不是 TDD 本身,而是主張邏輯分析程式的能力才重要(深思熟慮的分析應該應用在程式設計上)。

也就是說,用 TDD 開發或測試全過都不代表就是好程式。從他之前諷刺型別檢查器的發言也看得出這點。

Rich 雖然認同自動化自我測試的必要性,但(在那次發言後受到 TDD 支持者攻擊)表示「如果你覺得自己用的工具或方法論被批評不適合特定目的就像被攻擊,那需要放鬆一下」—— 方法論只是工具而已。

(4) 持續的 TDD 論戰揭示的真相

雖然不如以往熱烈,但 TDD 論戰為何持續?

仔細看論戰內容,很多時候把並非源自 TDD 的好處當成 TDD 帶來的好處。例如:

  • 一定要做 TDD 才能得到自動化自我測試嗎? 不是。
  • 一定要做 TDD 才能得到回歸測試嗎? 不是。
  • 一定要做 TDD 才有好設計(*什麼是好設計又是另一回事了)嗎? 不是。
  • TDD 用程式碼清楚表達規格,但一定要 TDD 才行嗎? 不是。

TDD 以 Red-Green-Refactor 循環進行的設計方法確實有效用,但不一定適合所有人或所有專案。而且如 Kent 在論戰中說的,mock 與 TDD 無關。因此需要明確分離設計技巧「TDD」的效用與「測試」的效用。

3. 對開發者來說什麼是好測試?

思考什麼是好測試之前,要先認知測試也是一種取捨。建立與維護測試需要開發者的資源。

測試確實對開發有幫助,但如 Martin Fowler 在對談中說的,存在不足與過度。那麼對開發者來說什麼是好測試?

(1) 該測試的程式碼 vs. 不該測試的程式碼

好測試是測試容易出錯部分的程式碼。

容易出錯的程式碼

  • 多處參照的程式碼
  • 核心邏輯程式碼
  • 未來可能變動的程式碼
  • QA 或實際服務時發生問題的程式碼

判斷這類程式碼光靠寫單元測試難以知道。這時用 BDD 很好 —— 在 Given/When/Then 情境思考測試案例,自然能知道該物件如何與其他物件關聯。

反過來說,判斷哪些領域可信任而決定不測試也很重要。

不易出錯的程式碼

  • 使用處不多的程式碼
  • 明確簡單的程式碼
  • 函式庫裡的功能

但選擇不測試的程式碼沒有絕對標準。比如假設某個開發團隊一般不測試函式庫程式碼,如果某個函式庫頻繁更新導致介面或行為常變動,使用該函式庫的程式碼就最好加上測試。

也就是說,判斷不測試的程式碼要依開發者所處情況充分考量。

(2) mock 一定要用嗎?

測試相關論戰中特別常提到 mock。

TDD 這類方法論基本上談的是單元測試,而單元測試應該在獨立環境快速驗證測試對象的行為 —— 依單元測試定義,測試單元所需的分層建議用 mock。

這話雖有道理,但 DHH 在 TDD 論戰中強調 mock 的害處,Kent 也表明做 TDD 時幾乎沒用 mock。Kent 更進一步擔心 mock 可能讓重構變困難。

使用 mock 根本上是為了 「獨立環境下的快速測試」

但並沒有規定一定要用 mock 才對。它只是構成測試的一種工具。

  • 獨立環境: 在多個物件互動運作的物件導向系統中,用 mock 做獨立的單元測試真的是測試對象的好測試嗎?
  • 速度: 不用 mock 而用實際分層物件,如果速度不慢,用實際物件有問題嗎?

(3) 自動化的自我回歸測試

「好測試」的另一個特徵是自動化的自我回歸測試。來看每個詞的意義:

  • 自動化(automated): 太理所當然沒特別提,但非常重要。如果執行測試需要額外作業,執行測試就會成為負擔。
  • 自我(self-checking): 功能要能自行測試是否正常運作。如果測試結果要人判斷,那(當然)也變成工作。
  • 回歸(regression): 要能確認原本正常運作的功能現在是否仍正常。

自動化的自我回歸測試無法保證所有程式碼的安全。但如果測試涵蓋經過深思判斷需要測試的部分,對那部分就能有信心。

再補充一點,做 TDD 時自然會建立自動化的自我回歸測試,但那不是 TDD 追求的目標。自動化的自我回歸測試可以不靠 TDD 建立。

也就是說 TDD 是有用的設計方法之一,最重要的是開發者判斷易出錯部分並精確撰寫自動化自我測試的能力。

其實開發者早在這些討論出現之前就一直在測試。用眼睛確認 log、畫面、印表機印出的值是否符合預期,有時寫執行程式的 shell script 確認程式輸出正確的值 —— 用眼睛或 shell script 指令確認。Kent 透過 TDD 與 SUnit 把這件事帶到正規化的領域,也帶來許多爭論。但爭論中沒人對自動化自我測試的效用有異議,因為優秀的開發者早就用某種方式在測試了。

參考資料

1. 譯書: http://www.yes24.com/Product/Goods/12246033, 原書: Test Driven Development: By Example: Beck, Kent: 8601400403228: Amazon.com: Books

2. Chrysler Comprehensive Compensation System - Wikipedia

3. JUnit Was Born on a Plane! – Tesla Tales (wordpress.com)

4. TDD is dead. Long live testing. (DHH)

5. "TDD는 죽었다" - Rails를 만든 DHH의 글 (sangwook.github.io)

6. TDD 공부 중. Is TDD dead? (junho85.pe.kr)

7. [한글화 프로젝트] TDD는 죽었는가? (tistory.com)

8. "Simple Made Easy" - Rich Hickey (2011) (youtube)