測試驅動開發(TDD)介紹中的誤區
目前我正在教授一個為期兩周的“敏捷開發實踐”速成課,參加培訓的團隊成員都是非常傳統的企業級Java開發者。要將社區中15年的進展濃縮到8個半天的實踐課程中非常具有挑戰性:在嚴格地時間約束下,教授什么思想和實踐才能對這些開發者的職業生涯提供***幫助呢?
經過幾天斷斷續續的思考,我至少得出了一個結論:傳統上會介紹給新人的測試驅動開發(TDD)將不會出現在我的課程里。
通常的TDD介紹存在著本質性的問題,它們通常會將學習者置于一個通往貌似目的地的道路上,但事實上并不能展示如何到達真正的目的地。這種現象太常見了,所以我決定給它取個名字:“現在該咋搞,朋友?”(WTF now, guys?)
Fig. 1 — 說真的,到底什么情況??
我認為這種情況解釋了為什么開發者對TDD的看法存在如此多分歧。當某個開發者發出這樣的抱怨:“到處都是mock對象,太可怕了”,另一個正在攀登更高山峰的開發者可能會回復“啊?到處都是Mock對象多好呀!”事實上,這是人們談論TDD時出現的典型情景,并且我相信出現這種問題的原因是:我們用相同的詞匯和工具描述完全不相關實踐。一個對于山峰前邊的人來說很合理的TDD問題,對于正在探索山峰另一邊的人來說可能完全是荒謬的。
如果我是對的(讀完下文后你可以自己判斷),我認為這個觀點揭示了為什么很多開發者曾經對TDD的許諾和初步使用經驗感到如此興奮,最終卻開始感到失望。
通過code katas(編碼實踐)教授“經典TDD”
我們先來看看如何使用code katas教授TDD。
首先我會簡單演示一下如何通過測試驅動出一個返回任意斐波那契數的函數。我會不斷對自己說,“這一整天的例子盡管不那么實用,但至少可以用來說明‘紅燈-綠燈-重構’的開發節奏”。稍后,我們會過一下Bob大叔的保齡球計分kata。當天培訓的***是由參加者自己結對實現一個羅馬數字到阿拉伯數組的轉換函數。
第二天,我會站在白板前讓同學們總結一下他們所體會的TDD的好處是什么。不出意外(但這點很重要),所有學員都將TDD看作是跟正確性相關的:“代碼沒有缺陷”、“自動化回歸測試代替手動測試”,“修改代碼不用擔心會改壞原有功能”等等。
當我對他們的回答評論說“TDD的主要好處是提高我們的代碼設計!”,他們顯得有些猝不及防。并且當我告訴他們,TDD所帶來的任何回歸測試安全性往好了說只是副作用,往壞了說可能只是個幻覺,他們開始左顧右盼,希望確保他們的老板沒有聽到我所說的。這聽起來可不像是他們最初希望得到的東西。
假設換種方式,我只是提供編碼練習,就像我每天所做的一樣,而忽略它們只是些簡單的練習題這一事實。當學生們發現他們在TDD編程練習中學到的經驗對平時的工作毫無幫助時,他們會有多么失望。
錯誤#1:鼓勵龐大的代碼單元
對初學者來說,如果你的目標是讓每個測試都對解決你的問題有直接幫助,那么***你就會得到功能越來越多的代碼單元。***個測試將會得到一些直接解決問題的程序代碼。第二個測試會帶來更多。第三個測試會讓你的設計更加復雜。TDD實踐本身不會在任何時候告訴你需要改進實現本身的設計——將大段的代碼分割成小段。
Fig. 2 — 考慮上圖,如果出現一個新需求,大多數開發者都會想到在現有的單元上增加額外的復雜性,而不會預先想到新需求需要通過增加一個新的單元來實現。
防止代碼設計變成一團亂麻變成了留給開發者的練習。這就是為什么很多TDD支持者要求在測試通過后增加一個“繁重的重構步驟”,因為他們意識到需要在這個流程中對開發者進行干預,以便讓他們能夠停一下,發現簡化設計的機會。
每次測試通過后進行重構是TDD支持者的原則(畢竟要遵守“紅燈-綠燈-重構”),不過在實踐中很多開發者經常錯誤地的跳過這個步驟,因為TDD過程沒有任何內在的規定強迫人們重構,直到***代碼變成一團亂麻。
一些培訓者會告誡開發者:嚴格的重構才能體現紀律性與專業性的美德,希望以此來解決這個問題。這對我來說這并不能算是個解決方案。與其去質疑那些做出巨大努力練習TDD的人們的職業素養,我寧愿去質疑在工具和練習的設計上是否能夠鼓勵人們在工作流中做正確的事。
錯誤2#:鼓勵費力的重構提取操作
假設在代碼單元開始變得龐大時,你會主動進行提取的重構操作。
Fig. 3 — 將單元的一部分職責提取到一個新的子單元中。不改變原始的測試以確保我們的重構沒有破壞任何東西。
不過需要知道,提取重構通常都會很痛苦。提取重構通常需要仔細的分析以及全神貫注,這樣才能將一個復雜的父對象梳理為一個整潔的子對象和一個不那么復雜的父對象。引用Brandon Keeper所說的“把兩個毛線團打成一個節,比把一個打了節的毛線團分成兩個毛線團要容易得多”。
錯誤3# 正確代碼的特性測試
即使重構工作順利完成了,還有很多工作要做!為了保證系統中每個單元都有對應的、設計良好的單元測試(我稱它為“對稱測試”),你需要設計新的單元測試來描述新的子對象行為。這種做法很有問題,因為特性測試是處理遺留代碼的測試工具,在真正的測試驅動開發中根本不應當出現。同時,如果我們將“特性測試”定義為“為沒有測試的單元添加測試以驗證其行為”,這正是在描述我們所做的:為已經實現的沒有相應單元測試的單元編寫測試。
因為新的測試并不是用正常的TDD節奏編寫的,開發人員面臨著與“實現后添加測試”情況同樣的風險。也就是說,因為代碼已經存在了,你的特性測試無法確保驗證到了新的子單元的全部行為。所以,即使你為了覆蓋新的單元,做了這么多額外的(也是值得稱贊的)工作,能達到的測試質量上限也始終比從頭進行測試驅動開發要低。這個結果說明了這種活動其實是種浪費。
Fig. 4 — 為新的子單元行為添加特性測試。我們需要對測試的健壯性持謹慎的態度,因為它是“開發后添加測試”的產物。
錯誤4# 冗余的測試覆蓋
但是現在你的系統面臨著另一個測試陷阱:冗余的測試覆蓋!同一行為在兩個地方都被覆蓋到對于TDD新手來說可能感覺很舒適,直到改動成本開始變得失控。
假設來了一個新的需求要求改動提取出來的子對象行為。理想情況下,這需要做三處改變(這三點是開發者都能夠預測到的):驗證新特性的集成測試、描述新行為的單元測試、以及單元代碼本身。但是在我們的冗余測試例子中,父單元的測試同樣需要做出修改。
更糟糕的是,實現這個變更的開發者根本想不到父對象的單元測試會失敗。也就是說,***的情況是,開發者面臨一個意想不到的“驚喜”:父單元的測試失敗了,需要額外的精力根據子對象的行為去重新設計父單元的測試。最差的情況可能是,開發者可能沒有意識到這個測試失敗其實是一個由于業務改變而導致的誤報,并不是一個真實的bug,這會導致大量時間耗費在發現父單元測試的失敗原因上。
Fig. 5 — 子對象的修改導致父對象的測試失敗,需要重新設計父對象的測試,即使父對象本身并沒有修改。
假設子對象被用在兩個地方——甚至10個地方!一個被依賴單元的簡單修改就會導致對依賴單元數小時的痛苦測試修復工作。
錯誤5# 以犧牲回歸有效性來消除冗余
如果我們希望避免冗余測試最終所帶來的痛苦,那么實現一個簡單的提取方法的重構就要求我們重新設計父單元的測試。
要知道父單元的測試原本是有正確性和回歸安全性保證的,所以原來的作者可能并不喜歡我為了移除冗余所做的事——把父單元中的子單元實例替換成它們的測試替身。
Fig. 6 —將父單元測試由原來的使用真實子單元實例替換成測試替身。
“現在這些測試就沒什么意義了,它們實際上驗證不了任何事!”最初的作者可能會這么說。根據當初編寫這些代碼的本意來說(TDD就是迭代式的解決問題,同時保證了完全的回歸安全),他們的意見是絕對正確的。可以這樣反駁他們的觀點“可是這些單元已經有獨立的測試了”,但是因為缺少額外的集成測試確保這些單元協同工作的正確性,原作者的擔心并不是沒有道理的。
在這一點上,我見多過很多團隊進入死胡同,一些人會很喜歡使用mock,另一些人則十分反對mock,但是沒有人真正理解這種爭論只是一種表象,它的根源是經典TDD給我們提供的錯誤假設。
錯誤6# 濫用Mock
雖然我通常會推薦團隊使用mock,但他們像如下這樣使用mock并不是個好主意。首先,將子單元替換成測試替身將會導致父單元的測試復雜化:測試代碼的一部分會表述父單元的邏輯行為,另一部分則會描述預期中的父單元與子單元協作方式。除了要處理以上的兩方面的內容,測試還綁定了父子單元如何協作的細節,因為任何調用都必須與父單元的實現邏輯相配套。
像這樣即描述了邏輯行為又描述了單元協作過程的測試是很難閱讀、理解與修改的。并且這種恐怖的情況可能遍及使用測試替身的大多數測試之中。這就難怪我總是聽到單元測試里有太多mock的抱怨,最近這個問題也很讓我困擾。
要解決這種濫用問題工作量也很大。父單元需要做重構,讓它只是引導其他單元的協作而本身不含有任何實現邏輯。這就要求父單元里那些之前沒有提取到子單元中的行為現在需要被抽取到另一個新的單元中(包括目前為止討論到的所有耗時的活動)。最終,父單元原始的測試將被拋棄,新的測試將只包含關于協作的描述,確保各子單元之間的交互是必須的。哦,由于現在完全沒有集成測試確保父單元工作正常,我們還需要添加一個集成測試。
天那,使用這套方法需要這么多精力以及紀律性才能維護一套整潔的代碼、可理解的測試、以及迅速的構建,這就難怪很少有團隊最終達到使用TDD所希望達成的目標。
#p#
成功應用TDD的方式
因此,我希望提供一個全新的課程,引入與上文描述完全不同的TDD工作流。
首先,考慮一下上文所述的痛苦曲折過程的最終產物:
一個父單元,依賴兩個子單元實現邏輯功能
父單元的測試,描述了兩個子單元的交互
兩個子單元,每個都有單元測試描述他們各自的職責
如果這就是我們要達到的最終目標,為什么不在最開始就朝著這個方向前進?我的TDD方法考慮到了這一點,并且可以做為簡化論的一個應用。
我的流程是這樣的:
(1) 拉入一個新的特性需求,這要求系統完成一些新的功能。
(2) 為特性看起來的復雜性感到恐慌。思考自己為什么一開始干上了編程這份工作。
(3) 為特性找到一個切入點,從建立一個公共接口契約開始(例如:“我會為控制器增加一個行為,返回給定年月的利潤值”)
此時也是將公共契約寫入集成測試的好機會。本文并不是關于集成測試的,不過我推薦運行于自己獨立進程的測試,它可以像真實用戶那樣與應用交互(例如通過HTTP請求)。如果從一開始就加入集成測試保證回歸安全性,我們的單元測試就不需要太多考慮集成測試的問題了。
(4) 為切入點編寫單元測試,不過不需要嘗試立刻去直接解決問題,要有意識地延遲編寫實現邏輯!應當這樣,假想你已經有了所需要的一些對象,通過這種方式來化簡問題(例如“如果這個控制器只依賴一個根據月份獲取收入的對象和一個根據月份獲取開支的對象,那一定很簡單”)。
由于這個步驟本身就鼓勵使用小型、單功能的單元,因此它能改善你的設計。
(5) 用TDD的方式實現切入點代碼,編寫測試就像那些假想的單元已經存在了。在切入點要用到的依賴對象處注入測試替身,在測試中描述它和依賴之間的交互。交互測試描述那些“協作”單元,它們只負責控制其他單元的使用,本身不包含邏輯。
這一步能夠改善你的設計,因為它給你機會去發現新依賴所必須擁有的API。如果某個交互很難測試,那么修改函數簽名會很容的,因為這些依賴目前還沒有實現。
(6) 對每個新想到的對象重復步驟4和5,發現更多更小粒度的協作對象。
受人類天性影響,人們在這一步時可能會感到比較恐慌(“這樣我們會得到無窮多微小的類!”),但在實踐中通過很好的代碼組織,這是可以管理的。由于每個對象都很小、容易理解、用途單一,因此通常由于需求變化而要刪除某個不再使用的單元,或是對象體系中的一整個子樹的對象并不會帶來很大痛苦。(我曾經遇到過 一份很不幸的代碼,里邊有很多龐大的、被到處使用的對象,因而基本上不可能刪除它們,即使它們已經不再符合當初的設計初衷了)。
(7) 最終,直到工作再無法細分。此時,在這些對象圖中處于葉子節點的對象中實現***的一點邏輯,之后重新回到樹的頂端開始下一個修改。
這個過程的目標就是發現盡可能多的協作對象,這樣就可以保證葉子節點只需要實現最簡單的邏輯。
“邏輯單元”的測試會詳盡得描述有效行為,并且可以讓作者有理由相信單元測試是完備與正確的。邏輯單元的測試可以保持簡單,因為沒有必要使用測試替身——只需要根據不同的輸入驗證對應的輸出結果。
我喜歡把這種過程成為“Fake it until you make it”(使用偽對象,直到你實現它),雖然這其實是來自GOOS一書中的敏銳觀點,但它更強調了簡約化。我還發現區分“協作單元”與“邏輯單元”是很有價值的,不但能夠使測試更清晰而且也增加了代碼的一致性。
同時注意到采用這種TDD方式不需要加入繁重的重構步驟。提取重構操作成為一種特例而不是常規行為,這就意味著上文詳細描述的提取重構帶來的后續附加成本能夠完全避免。
改變我們教授TDD的方式
我用了四年時間才完全理解我使用TDD所遇到的挫折,并將這些思考寫成了這篇文章。經過對這些問題長時間的徘徊與思索,我可以說最終我認為TDD是一個高效的、愉快的實踐。不值得為所有的項目嘗試投入那么多TDD時間,但在構建一個預期會生存很長時間的系統時,TDD是能夠幫助我們戰勝焦慮和復雜性的一個有效工具。
我將這些分享給大家的目的是告訴大家這才是真正的測試驅動開發。經典TDD的簡單假設給新人帶來的痛苦并不能讓他們學到太多東西。讓我們一起找到一種方法能夠將更有效的TDD工作流教授給學員,讓他們可以立刻使用這些有效的工具將令人困惑的大問題拆分成為可以控制的小問題。
原文鏈接: testdouble 翻譯: 伯樂在線 - 治不好你我就不是獸醫
譯文鏈接: http://blog.jobbole.com/64431/