1. 程式人生 > >TDD(測試驅動開發)

TDD(測試驅動開發)

本文結構:

  • 什麼是 TDD
  • 為什麼要 TDD
  • 怎麼 TDD
  • FAQ
  • 學習路徑
  • 延伸閱讀

什麼是 TDD

TDD 有廣義和狹義之分,常說的是狹義的 TDD,也就是 UTDD(Unit Test Driven Development)。廣義的 TDD 是 ATDD(Acceptance Test Driven Development),包括 BDD(Behavior Driven Test Development)和 Consumer-Driven Contracts Development 等。
本文所說的 TDD 指狹義上的 TDD,也就是「單元測試驅動開發」。

TDD 是敏捷開發中的一項核心實踐和技術,也是一種設計方法論。TDD的原理是在開發功能程式碼之前,先編寫單元測試用例程式碼,測試程式碼確定需要編寫什麼產品程式碼。TDD 是 XP(Extreme Programming)的核心實踐。它的主要推動者是 Kent Beck。

TDD 有三層含義:

  • Test-Driven Development,測試驅動開發。
  • Task-Driven Development,任務驅動開發,要對問題進行分析並進行任務分解。
  • Test-Driven Design,測試保護下的設計改善。TDD 並不能直接提高設計能力,它只是給你更多機會和保障去改善設計。

為什麼要 TDD

傳統編碼方式 VS TDD 編碼方式

傳統編碼方式

  • 需求分析,想不清楚細節,管他呢,先開始寫
  • 發現需求細節不明確,去跟業務人員確認
  • 確認好幾次終於寫完所有邏輯
  • 執行起來測試一下,靠,果然不工作,除錯
  • 除錯好久終於工作了
  • 轉測試,QA 測出 bug,debug, 打補丁
  • 終於,程式碼可以工作了
  • 一看程式碼爛的像坨屎,不敢動,動了還得手工測試,還得讓 QA 測試,還得加班...

TDD 編碼方式

  • 先分解任務,分離關注點(後面有演示)
  • 列 Example,用例項化需求,澄清需求細節
  • 寫測試,只關注需求,程式的輸入輸出,不關心中間過程
  • 寫實現,不考慮別的需求,用最簡單的方式滿足當前這個小需求即可
  • 重構,用手法消除程式碼裡的壞味道
  • 寫完,手動測試一下,基本沒什麼問題,有問題補個用例,修復
  • 轉測試,小問題,補用例,修復
  • 程式碼整潔且用例齊全,信心滿滿地提交

TDD 的好處

降低開發者負擔
通過明確的流程,讓我們一次只關注一個點,思維負擔更小。

保護網
TDD 的好處是覆蓋完全的單元測試,對產品程式碼提供了一個保護網,讓我們可以輕鬆地迎接需求變化

改善程式碼的設計
所以如果你的專案需求穩定,一次性做完,後續沒有任何改動的話,能享受到 TDD 的好處就比較少了。

提前澄清需求
先寫測試可以幫助我們去思考需求,並提前澄清需求細節,而不是程式碼寫到一半才發現不明確的需求。

快速反饋
有很多人說 TDD 時,我的程式碼量增加了,所以開發效率降低了。但是,如果沒有單元測試,你就要手工測試,你要花很多時間去準備資料,啟動應用,跳轉介面等,反饋是很慢的。準確說,快速反饋是單元測試的好處。

怎麼 TDD


TDD

TDD 的基本流程是:紅,綠,重構。
更詳細的流程是:

  • 寫一個測試用例
  • 執行測試
  • 寫剛好能讓測試通過的實現
  • 執行測試
  • 識別壞味道,用手法修改程式碼
  • 執行測試

你可能會問,我寫一個測試用例,它明顯會失敗,還要執行一下嗎?
是的。你可能以為測試只有成功和失敗兩種情況,然而,失敗有無數多種,執行測試才能保證當前的失敗是你期望的失敗。
一切都是為了讓程式符合預期,這樣當出現錯誤的時候,就能很快定位到錯誤(它一定是剛剛修改的程式碼引起的,因為一分鐘前程式碼還是符合我的預期的)。
通過這種方式,節省了大量的除錯程式碼的時間。

TDD 的三條規則

  1. 除非是為了使一個失敗的 unit test 通過,否則不允許編寫任何產品程式碼
  2. 在一個單元測試中,只允許編寫剛好能夠導致失敗的內容(編譯錯誤也算失敗)
  3. 只允許編寫剛好能夠使一個失敗的 unit test 通過的產品程式碼

如果違反了會怎麼樣呢?
違反第一條,先編寫了產品程式碼,那這段程式碼是為了實現什麼需求呢?怎麼確保它真的實現了呢?
違反第二條,寫了多個失敗的測試,如果測試長時間不能通過,會增加開發者的壓力,另外,測試可能會被重構,這時會增加測試的修改成本。
違反第三條,產品程式碼實現了超出當前測試的功能,那麼這部分程式碼就沒有測試的保護,不知道是否正確,需要手工測試。可能這是不存在的需求,那就憑空增加了程式碼的複雜性。如果是存在的需求,那後面的測試寫出來就會直接通過,破壞了 TDD 的節奏感。

我認為它的本質是:
分離關注點,一次只戴一頂帽子
在我們程式設計的過程中,有幾個關注點:需求,實現,設計。
TDD 給了我們明確的三個步驟,每個步驟關注一個方面。
紅:寫一個失敗的測試,它是對一個小需求的描述,只需要關心輸入輸出,這個時候根本不用關心如何實現。
綠:專注在用最快的方式實現當前這個小需求,不用關心其他需求,也不要管程式碼的質量多麼慘不忍睹。
重構:既不用思考需求,也沒有實現的壓力,只需要找出程式碼中的壞味道,並用一個手法消除它,讓程式碼變成整潔的程式碼。

注意力控制
人的注意力既可以主動控制,也會被被動吸引。注意力來回切換的話,就會消耗更多精力,思考也會不那麼完整。
使用 TDD 開發,我們要主動去控制注意力,寫測試的時候,發現一個類沒有定義,IDE 提示編譯錯誤,這時候你如果去建立這個類,你的注意力就不在需求上了,已經切換到了實現上,我們應該專注地寫完這個測試,思考它是否表達了需求,確定無誤後再開始去消除編譯錯誤。

為什麼很多人做 TDD 都做不起來?

不會合理拆分任務
TDD 之前要拆分任務,把一個大需求拆成多個小需求。
也可以拆出多個函式來。

不會寫測試
什麼是有效的單元測試,有很多人寫測試,連到底在測什麼都不清楚,也可能連斷言都沒有,通過控制檯輸出,肉眼對比來驗證。
好的單元測試應該符合幾條原則:

  • 簡單,只測試一個需求
  • 符合 Given-When-Then 格式
  • 速度快
  • 包含斷言
  • 可以重複執行

不會寫剛好的實現
很多人寫實現的時候無法專注當前需求,一不小心就把其他需求也實現了,就破壞了節奏感。
實現的時候不會小步快走。

不會重構
不懂什麼是 Clean Code,看不出 Smell,沒有及時重構,等想要重構時已經難以下手了。
不知道用合適的「手法」消除 Smell。

基礎設施
對於特定技術棧,沒有把單元測試基礎設施搭建好,導致寫測試時無法專注在測試用例上。

例項

寫一個程式來計算一個文字檔案 words.txt 中每個單詞出現的頻率。
為了保持簡單,假設:

  • words.txt 只包含小寫字母和空格
  • 每個單詞只包含小寫字母
  • 單詞之間由一個或多個空格分開

舉個例子,假設 words.txt 包含以下內容:

the day is sunny the the
the sunny is is

你的程式應當輸出如下,按頻率倒序排序:

the 4
is 3
sunny 2
day 1

請先不要往下讀,思考一下你會怎麼做。
(思考 3 分鐘...)

新手拿到這樣的需求呢,就會把所有程式碼寫到一個 main() 方法裡,虛擬碼如下:

main() {
    // 讀取檔案
    ...
    // 分隔單詞
    ...
    // 分組
    ...
    // 倒序排序
    ...
    // 拼接字串
    ...
    // 列印
    ...
}

思路很清晰,但往往一口氣寫完,最後執行起來,輸出卻不符合預期,然後就開始打斷點除錯。

這種程式碼沒有任何的封裝。這就是為什麼很多人一聽到說有些公司限制一個方法不超過 10 行,就立馬跳出來說,這不可能,10 行能幹什麼啊,我們的業務邏輯很複雜...
這樣的程式碼存在什麼樣的問題呢?

  • 不可測試
  • 不可重用
  • 難以定位問題

好嘛,那我們來 TDD 嘛,你說讀檔案,輸出控制檯的測試程式碼要怎麼寫?
當然,我們可以通過 Mock 和 Stub 來隔離 IO,但真的有必要嗎?

有人問過 Kent Beck 這樣一個問題:

你真的什麼都會測嗎?連 getter 和 setter 也會測試嗎?

Kent Beck 說:公司請我來是為了實現業務價值,而不是寫測試程式碼。
所以我只在沒有信心的地方寫測試程式碼。

那對我們這個程式而言,讀檔案和列印到控制檯都是呼叫系統 API,可以很有信心吧。最沒有信心的是中間那寫要自己寫的業務邏輯。
所以我們可以對程式做一些封裝,《程式碼整潔之道》裡說,有註釋的地方都可以抽取方法,用方法名來代替註釋:

main() {
    String words = read_file('words.txt')
    String[] wordArray = split(words)
    Map<String, Integer> frequency = group(wordArray)
    sort(frequency)
    String output = format(frequency)
    print(output)
}

這樣是不是就可以單獨為 splitgroupsortformat 這些方法寫單元測試了呢?
當然可以, 它們的輸入和輸出都是很明確的嘛。

等等,你可能會說,不是測試驅動設計嗎?你怎麼開始做設計了?好問題!

TDD 要不要做提前設計呢?

Kent Beck 不做提前設計,他會選一個最簡單的用例,直接開寫,用最簡單的程式碼通過測試。逐漸增加測試,讓程式碼變複雜,用重構來驅動出設計。
在這個需求裡,最簡單的場景是什麼呢?
那就是檔案內容為空,輸出也為空。

當然,對於複雜問題,可能要一邊寫一邊補充新的用例,但對於這種簡單的題目,基本可以提前就想清楚用什麼用例驅動去什麼產品程式碼。
大概可以想到如下的用例:

  • "" => ""
  • "he" => "he 1",一個單詞,驅動出格式化字串的程式碼
  • "he is" => "he 1\r\nis 1",兩個不同單詞,驅動出分割單詞的程式碼
  • "he he is" => "he 2\r\nis 1",有相同單詞,驅動出分組程式碼
  • "he is is" => "is 2\r\nhe 1",驅動出分組後的排序程式碼
  • "he is" => "he 1\r\nis 1",多個空格,完善分割單詞的程式碼

Martin Fowler 的觀點是,以前我們寫程式碼要做 Big Front Up Design,在開始寫程式碼前要設計好所有細節。
而我們有了重構這個工具後,做設計的壓力小了很多,因為有測試程式碼保護,我們可以隨時重構實現了。但這並不代表我們不需要做提前設計了,提前設計可以讓我們可以和他人討論,可以先迭代幾次再開始寫程式碼,在紙上迭代總比改程式碼要快。
我個人比較認同 Martin Fowler 的做法,先在腦子裡(當然,我腦子不夠用,所以用紙畫)做設計,迭代幾次之後再開始寫,這樣,我還是會用最簡單的實現通過測試,但重構時就有了方向,效率更高。

回到這個程式,我發現目前的封裝不在一個抽象層次上,更理想的設計是:


分解任務
main() {
    String words = read_file('words.txt')
    String output = word_frequency(words)
    print(output)
}

word_frequency(words) {
    String[] wordArray = split(words)
    Map<String, Integer> frequency = group(wordArray)
    sort(frequency)
    return format(frequency)
}

這時候,又有兩種選擇,有人喜歡自頂向下,有人喜歡自底向上,我個人更傾向於前者。

現在開始,只要照著 紅-綠-重構 的迴圈去做就可以。
大部分 TDD 做不好,就是沒有前面的任務分解和列 Example 的過程。
想看 TDD 過程的話,可以參考我做的直播
或者如果需要,我也可以錄一個這個題目的視訊。

FAQ

為什麼一定要先寫測試,後補測試行不行?

行,但是要寫完實現後,馬上寫測試,用測試來驗證實現。如果你先手工測試,把程式碼都除錯好了,再補單元測試,你就會覺得很雞肋,還增加了工作量。
不管測試先行還是後行都可以享受到快速反饋,不過如果測試先行,你就可以享受另一個好處,使用意圖驅動程式設計減少返工。因為你的測試程式碼就是產品程式碼的客戶端(呼叫者),你可以在測試程式碼裡寫成你理想的樣子(方法名,引數,返回值等),再去實現產品程式碼,比起先寫實現後寫測試,前者返工更少。

剛寫了一個測試,還沒寫實現。明知道執行測試一定會報錯,為什麼還要去執行?

其實測試的執行結果並非只有通過與不通過兩種,因為不通過時有很多種可能。所以在明知道一定失敗的情況下去執行測試,目的是看看是不是報了期望的那個錯誤。

小步快走確實好,但真的需要這麼小步嗎?

步子邁太大,容易扯著蛋。
練習的時候需要養成小步的習慣,工作的時候可以自由切換步子的大小。
當你自信的時候步子就可以大點,當你不太自信的時候就可以立即切換到小步的模式。如果只會大步,就難以再小步了。

測試程式碼是否會成為維護的負擔?

維護時也遵循 TDD 流程,先修改測試程式碼成需求變更後的樣子,讓測試失敗,再修改產品程式碼使其通過。
這樣你就不是在維護測試用例,而是在利用測試用例。

為什麼要快速實現?

其實是用二分查詢法隔離問題,通過 hardcode 實現通過測試後,就基本確定測試是沒有問題,這時再去實現產品程式碼,如果測試不通過,就是產品程式碼的問題。
所以小步快走主要是為了隔離問題,也就是你可以告別 Debug 了。

為什麼測試程式碼要很簡單?

如果一個測試失敗了,修復的時候是改測試程式碼而不是產品程式碼,那就是測試程式碼寫的不好。
當測試程式碼足夠簡單時,如果一個測試失敗了,就有足夠信心斷定一定是產品程式碼的問題。

什麼時候不適合 TDD?

如果你是做探索性的技術研究(Spike),不需要長期維護,而且測試基礎設施搭建成本很高,那還是手工測試吧。
另外還有「可測試性極差的遺留系統」和「使用測試不友好的技術棧」的系統,做 TDD 可能得不償失。

學習路徑

  1. 《重構》
  2. 《Test-Driven Development by Example》
  3. 《Growing Object-Oriented Software, Guided by Tests》

延伸閱讀

Q&A

問:在一個團隊中誰來寫單元測試呢,開發還是測試?開發測試如何協作呢?分開的方式會不會導致測試用例質量的下降?畢竟要實現的功能只有開發最清楚,會不會導致覆蓋率降低?
答: TDD 是開發人員自己的事。基本不需要和測試協作,不過我司的測試已經開始參與單元測試的 Review 了。

問:TDD是否適合介面的開發?應用的業務邏輯部分可以用TDD這個可以理解,不過,介面部分是否也適用?如果可以的話,大概是如何操作呢?比如安卓,是用安卓提供的測試框架麼?那隻能測試簡單的介面切換之類的功能,有沒有其他更好的方式?
答:前幾年我們做過一個 Android 應用,用 Robolectric 做的單元測試。http://robolectric.org/
移動應用開發挺適合的,因為你就不用啟動應用來測試了。反饋會比較快,也不用去做資料,去跳轉頁面。Robolectric 不用啟虛擬機器,速度非常快,在 JVM 裡跑的。也可以用 Appium 做端到端的測試。

問:有關於怎樣分解任務的策略,拿到一個任務,如何開始第一步?
答:關於任務分解可以讀這個系列文章,學會這個,就可以在不動鍵盤的情況下想清楚程式碼的結構,在腦子裡做幾遍重構,再寫的時候就效率很高。腦子想不明白可以藉助紙筆來畫。有了這個圖你就可以和別人交流了。帶人的時候尤其好用,你佈置完一個任務,讓新人先畫圖,就可以在他開始寫程式碼前對他的設計提反饋。

問:按流程一路寫測試,一路實現,還是隨便挑一個開始測試與實現,然後mock 資料呢?
答:有從上到下的,也有從下到上的兩種途徑。我個人傾向於從上到下的。或者說從外到內的。從下到上,有可能最後連不起來。

問:單元測試與整合測試,怎麼權衡,開發應該注重單元測試還是整合測試,還是說都要關注?
答:測試是分層的,都要關注。各種測試要搭配起來,各有各的好處。

問:如何在緊張的工期和完善的單元測試之間進行權衡?
答:這是個偽命題。TDD 不是慢。

問:兩週一個版本迭代,適合用TDD嗎?
答:特別適合。沒有完善的測試覆蓋,兩週釋出一個版本是很困難的。有 TDD 才容易做到快速交付,寫完測試,實現,跑完構建流程,部署流水線,就釋出了。

問:我比較關心 Robert 大叔說的那個TDD優先順序 ,這個如何理解和應用?好像確實很容易寫著就死衚衕了。
答:這個就是 TDD 中綠的部分,Baby Step 能做到多細。Uncle Bob 的 TPP 給我們指了一條明路。

問:TDD測試驅動開發,有哪些注意事項,測試和開發應該注意哪些內容?
答:小步快走,頻繁提交,注意休息。

問:對於單元測試的mock,應該針對外部介面進行mock嗎?那麼,再提交測試之前,是否要關注外部介面的返回結果是否按照約定返回?
答:單元測試保證的是自己的邏輯。外部介面的質量有外部系統來保證。我們也有整合測試來保證。

問:請老師分享一下寫單元測試好的實踐,及各種單元測試優缺點(使用詳解就更好了)。
答:三個原則,Given-When-Then,FIRST,BICEP-Right。