深度解讀 - TDD(測試驅動開發(fā))

本文結構:

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

什么是 TDD

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

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

TDD 有三層含義:

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

為什么要 TDD

傳統(tǒng)編碼方式 VS TDD 編碼方式

傳統(tǒng)編碼方式

  • 需求分析,想不清楚細節(jié),管他呢,先開始寫
  • 發(fā)現(xiàn)需求細節(jié)不明確,去跟業(yè)務人員確認
  • 確認好幾次終于寫完所有邏輯
  • 運行起來測試一下,靠,果然不工作,調試
  • 調試好久終于工作了
  • 轉測試,QA 測出 bug,debug, 打補丁
  • 終于,代碼可以工作了
  • 一看代碼爛的像坨屎,不敢動,動了還得手工測試,還得讓 QA 測試,還得加班...

TDD 編碼方式

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

TDD 的好處

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

保護網
TDD 的好處是覆蓋完全的單元測試,對產品代碼提供了一個保護網,讓我們可以輕松地迎接需求變化改善代碼的設計。
所以如果你的項目需求穩(wěn)定,一次性做完,后續(xù)沒有任何改動的話,能享受到 TDD 的好處就比較少了。

提前澄清需求
先寫測試可以幫助我們去思考需求,并提前澄清需求細節(jié),而不是代碼寫到一半才發(fā)現(xiàn)不明確的需求。

快速反饋
有很多人說 TDD 時,我的代碼量增加了,所以開發(fā)效率降低了。但是,如果沒有單元測試,你就要手工測試,你要花很多時間去準備數(shù)據(jù),啟動應用,跳轉界面等,反饋是很慢的。準確說,快速反饋是單元測試的好處。

怎么 TDD

TDD

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

  • 寫一個測試用例
  • 運行測試
  • 寫剛好能讓測試通過的實現(xiàn)
  • 運行測試
  • 識別壞味道,用手法修改代碼
  • 運行測試

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

TDD 的三條規(guī)則

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

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

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

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

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

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

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

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

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

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

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

實例


寫一個程序來計算一個文本文件 words.txt 中每個單詞出現(xiàn)的頻率。
為了保持簡單,假設:

  • 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 行能干什么啊,我們的業(yè)務邏輯很復雜...
這樣的代碼存在什么樣的問題呢?

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

好嘛,那我們來 TDD 嘛,你說讀文件,輸出控制臺的測試代碼要怎么寫?
當然,我們可以通過 Mock 和 Stub 來隔離 IO,但真的有必要嗎?

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

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

Kent Beck 說:公司請我來是為了實現(xiàn)業(yè)務價值,而不是寫測試代碼。
所以我只在沒有信心的地方寫測試代碼。

那對我們這個程序而言,讀文件和打印到控制臺都是調用系統(tǒng) API,可以很有信心吧。最沒有信心的是中間那寫要自己寫的業(yè)務邏輯。
所以我們可以對程序做一些封裝,《代碼整潔之道》里說,有注釋的地方都可以抽取方法,用方法名來代替注釋:

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)
}

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

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

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,在開始寫代碼前要設計好所有細節(jié)。
而我們有了重構這個工具后,做設計的壓力小了很多,因為有測試代碼保護,我們可以隨時重構實現(xiàn)了。但這并不代表我們不需要做提前設計了,提前設計可以讓我們可以和他人討論,可以先迭代幾次再開始寫代碼,在紙上迭代總比改代碼要快。
我個人比較認同 Martin Fowler 的做法,先在腦子里(當然,我腦子不夠用,所以用紙畫)做設計,迭代幾次之后再開始寫,這樣,我還是會用最簡單的實現(xiàn)通過測試,但重構時就有了方向,效率更高。

回到這個程序,我發(fā)現(xiàn)目前的封裝不在一個抽象層次上,更理想的設計是:

分解任務
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)
}

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


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

FAQ

為什么一定要先寫測試,后補測試行不行?

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

剛寫了一個測試,還沒寫實現(xiàn)。明知道運行測試一定會報錯,為什么還要去運行?

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

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

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

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

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

為什么要快速實現(xiàn)?

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

為什么測試代碼要很簡單?

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

什么時候不適合 TDD?

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

學習路徑

  1. 《有效的單元測試》
  2. 《代碼整潔之道》
  3. 《重構》
  4. Transformation Priority Premise
  5. 《Test-Driven Development by Example》
  6. 《Growing Object-Oriented Software, Guided by Tests》

延伸閱讀

本文最初發(fā)布于 GitChat,文章已免費發(fā)布,歡迎購買問答實錄。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,837評論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,196評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,688評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,654評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,456評論 6 406
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,955評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,044評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,195評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經...
    沈念sama閱讀 48,725評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,608評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,802評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,318評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,048評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,422評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,673評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,424評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,762評論 2 372

推薦閱讀更多精彩內容