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

轉自:http://www.jianshu.com/p/62f16cd4fef3


本文結構:

  • 什麼是 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. 《代碼整潔之道》
  3. 《重構》
  4. Transformation Priority Premise
  5. 《Test-Driven Development by Example》
  6. 《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。



作者:SeabornLee
鏈接:http://www.jianshu.com/p/62f16cd4fef3
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章