最近CoolShell上的一篇《TDD並不是看上去的那麼美》引起了敏捷社區的高度關注和激勵辯論。今天,InfoQ甚至專門舉行了一個“虛擬座談會”《TDD有多美?》,幾位國內敏捷社區的名人專門就此問題展開了深入地討論。不論結果如何,這種探討和反思的精神還是非常值得讚賞的。事件實際上可以簡單地歸納為“一個有一定影響力的開發人員質疑TDD,一群敏捷社區名人對TDD進行解釋和辯護”。現在,就讓我堅定地站在CoolShell一邊,為對TDD的質疑和批判添磚加瓦吧!
我們首先來看看TDD的核心理念是什麼。第一是“用例即規範”(Specification by Example),即把測試案例作為需求規範的一種形式。傳統的需求表達方式包括文檔,Use Case等,而TDD強調通過測試案例來表達需求。另外,TDD的測試案例是黑盒的基於外部介面的,所以,它實際上又是對外部介面的設計。“不把測試案例單純地視為測試,而從需求和設計的角度來看測試案例”是TDD與傳統測試的一個重要區別。TDD的第二個重要理念是Test First,強調測試對於實現的驅動作用,先寫測試案例,再實現和重構。Test First的實質是“先理解清楚需求,並做好外部介面設計,把它轉化為測試案例,然後再來實現和重構”。
如果說“用例即規範”還彌補了文檔和Use Case在表達需求時的某些不足,具有一定的好處,那麼
Test First則有很大的問題,尤其“在沒有測試案例失敗之前,不要寫任何一行代碼”的極端方式則更是極端的錯誤。
如果測試案例就是需求和設計,那麼為什麼不能先寫出測試案例再來實現呢?這不是我們最熟悉的先需求再設計再編碼嗎?答案是:不能執行的測試案例(Test First)和能執行的測試案例有著天壤之別,
你寫出了測試案例不代表你就看到了啟動並執行實際效果。不能執行的測試案例和寫在紙上的文檔相比對實現的指導意義不見得能好到哪裡去!除非是一些很簡單的情況下,在實際的軟體開發中,你很難在沒有執行測試案例的情況下寫出真正符合最終需求的測試案例來。比如:你做一個頁面,頁面的效果需求和設計通常會在真正可以運行之後不斷調整,在實現之前只能有一個大致的輪廓和方向,許多方面的細節要麼是沒想清楚,要麼是完全沒想到,不可能一蹴而就。如果片面強調測試對實現的驅動作用,那麼實際上隱含了“需求和設計的細節可以在實現之前明確下來”的假設,這是非常不敏捷的和不現實的!
Test First要求寫測試案例時對軟體需求有精確的瞭解,但實際軟體開發過程中使用者需求和外部環境的不確定性會導致軟體需求難以把握和頻繁變動。
使用者需求的不確定性是指“需求無法在使用者真正能運行看到效果之前明確下來”。比如:讓你開發一套Wow這樣大型的遊戲,你能想象遊戲的效果是設計者一開始就想好了精確到每一個細節嗎?對於遊戲這樣的軟體,需求和設計不可能脫離實際運行紙上談兵地產生。遊戲的設計者通常只能藉助文檔、草圖、Use Case等非精確的方式大致提出需求,先做出原型,在看到效果之後才能逐步地細化和明確,需求設計的增加和改變會伴隨整個軟體開發過程。另外,還有一種極端的情況是根本不存在精確的使用者需求,比如:自動化翻譯軟體,你能在實現之前就把翻譯效果用測試案例固定下來嗎?存在絕對正確的翻譯方法嗎?最近,我們和國外一家大公司客戶談一個項目需求的時候,客戶講了這樣一句話“我們現階段還無法提出很細緻的需求,只有等你們拿出第一個版本,然後我們再逐步地調整細化”。我們的客戶沒有宣稱自己在做敏捷,但人家的思維方式多敏捷啊,不是什麼一上來就明確需求,而且還要精確寫出自動化測試案例。有人說這種情況我們仍然可以先根據自己的理解進行TDD,這樣做可以:1.基於測試案例和客戶溝通明確需求;2.驅動實現。我對此持不同看法,能執行的測試案例和不能執行的測試案例有著天壤之別,客戶從測試案例根本無法獲得真實啟動並執行體驗,你能想象蘋果把iPhone的測試案例寫在PPT上,給使用者做一個演講,使用者就能給出關於iPhone設計的反饋了嗎?要真正的使用者反饋,就需要實打實的軟體,這不正是敏捷的“Working software over document”的思想嗎?另外,既然使用者無法在實際體驗之前提出反饋,那麼開發人員在開發初期做的需求分析和設計都只是一個探索,隨時可能調整甚至被推翻,不值得在實現之前進行自動化測試設計的投資。
外部環境的不確定性是指"當我們的系統需要和外部系統整合時,關於外部系統行為的假設也無法在實際整合運行前完全確定"。例如,要做一套股票用戶端連上證券交易所系統,因為證券交易所的行為會直接影響到用戶端的開發,所以只有在弄清證券交易所行為的情況下才談得上開發出高品質的用戶端。如果採用測試驅動,編寫了各種涉及證券交易所行為的測試案例,比如什麼情況下發什麼類型的訊息,訊息格式如何,如何互動等等,但是這些測試案例本身是否正確卻需要打一個大大的問號!這一方面是由於很多證券交易所提供的協議都不夠清晰或者有許多未明確定義的地方;另一方面即使協議沒有問題,開發人員也可能由於單純的失誤或者缺乏相應領域的基本知識而把協議理解錯。實際上,要真正弄清證券交易所的行為明確用戶端的需求,最重要的手段還是在證券交易所提供的測試環境中跑整合測試。對於Test First來講,測試案例本身的錯誤可以說是代價最大的,不僅浪費時間和精力,更重要的是還打擊開發人員計程車氣,誰願意來回折騰呢?但很不幸,實際情況是在最初沒有明確證券交易所行為的時候Test First出來的測試案例隨時可能在真實整合後被推翻,並且如果是比較高層的需求分析失誤,那對整個架構設計來講會是災難性的後果。在實際開發中,我們的軟體需要和其他系統整合的情況是非常普遍的,而期望在沒有進行實際整合的情況下弄清外部系統的行為都是不現實和不敏捷的。
所以,Test First需要對於被測系統的需求和環境有精確的瞭解,但由於需求不確定性和外部環境不確定性兩大問題,Test First在很多時候都是不現實的。其實,Test
First和瀑布式思想一脈相承,都強調需求先於實現,而忽略了軟體需求的產生會受到實現的反饋,會在實際運行中不斷調整探索完善。TDD無非是把需求分析的結果用測試案例表達,替代傳統用文檔表達需求,但從宏觀上看,TDD和瀑布比是換湯不換藥,這都不是真正的敏捷。除了簡單情況,不存在脫離實現的需求,你能夠在明確了需求之後就實現出一套Linux系統嗎?既然你根本無法實現一套Linux系統,那麼這樣所謂的需求又有多大的意義呢?所以,能提出什麼樣的需求不能脫離你的實現能力。需求和實現之間不是簡單的誰驅動誰,而是一種相互反饋的關係,這與需求用什麼方式表達沒有關係。正如瀑布模型無法在初始階段做出完美的需求分析,TDD也無法在初始階段做出完美的測試案例;不僅如此,自動化測試案例的開發維護成本還遠高於文檔。所以,在敏捷環境中,軟體開發初期應該通過文檔和用例等手段大致表達需求,實現之後在實際運行中體驗效果,不斷最佳化探索和明確需求和外部環境,當需求和對外部環境的認識達到一個比較穩定的程度才編寫測試案例將需求固化下來。
上面的論述主要針對貼近終端使用者的外部需求(如ATDD),下面我會進一步解釋即使是在內部的單元測試層級TDD仍然有問題。我們還是首先從需求入手,思考一下單元的需求是哪裡來的呢?答案是:需求來自於設計!比如,對輪胎的需求來源於汽車的設計,低層模組的需求來源於高層模組的設計。而在開發初期,這種內部設計具有很大的不穩定性,帶有很多假設的成分,在沒有進行整合測試的情況下,很難講這種內部設計是否合理。實際項目開發通常會在整合運行之後不斷調整內部的設計,即影響單元的需求。那麼,如果是測試驅動,首先按不成熟的內部設計把一個個單元需求編寫成單元測試再來實現,實際上大大延遲了能進行整合測試的時間,對於真正快速弄清高層需求穩定設計反而是不利的。假設最終還是所有單元都完成,然後開始運行整合或驗收測試,這時候有兩種可能:1.使用者看到實際效果,決定調整需求;2.發現整合前在單元層面的假設不成立或者是有沒有考慮到的情況。不論是哪一種情況發生,以前所寫的單元測試都面臨著被廢棄或必須修改的命運。實際上,多數與業務相關的單元測試用例比起整合或驗收測試用例更加不穩定,因為它會受到所有其上層模組的需求和設計變動的影響。由於我們在不穩定的單元測試上浪費了大量的時間(按我的經驗編寫單元測試比編寫實現更耗時),這就導致了遲遲無法進行整合看到實際效果,也沒有辦法敏捷地應對需求的調整。也就是說具有諷刺意味的,
Test First理念居然是和敏捷理念矛盾的!
所以,我認為Test First不符合敏捷開發的基本假設,而真正符合敏捷的理念是“需求和設計依賴於實現的反饋,需要在實際運行過程中根據效果不斷探索調整得來的,不可能脫離實際運行寫出真正符合最終需求的測試案例來”。所以,我們真正應該做的是儘快看到實際啟動並執行效果,而自動化測試作為固化的需求和設計是在看到效果之後。在整合之前花太多精力進行測試驅動只會導致遲遲看不到實際運行效果(尤其是基於開發人員自己的假設編寫大量單元測試用例),看到效果需要調整需求又會廢掉或改掉一大堆的測試案例。實際上,越是外部的需求其變更帶來的影響和代價越大,越是需要儘早明確。從宏觀上看,TDD所謂的快速反饋實際上是加快內部反饋,延遲了外部反饋,這無異於本末倒置。而大量需要修改或作廢的測試案例其實是一種很大的浪費,這和消除浪費的精益思想也是矛盾的!
上面這幅cost/length_of_feedback_cycle圖是我們常見的用於說明敏捷方法比傳統方法具有更短的反饋周期,更小代價的應對變化。我們可以清晰的看到在驗收測試中發現的需求錯誤導致的代價是最高的。如果驗收測試往後延遲一點,發現錯誤的代價將按非線性地增長。上面我們已經論述了,任何方法都不可能消除驗收測試後對需求的調整,因為這是需求產生的正常過程。我們唯一可以做的是儘可能地縮短驗收測試的反饋周期,但是很不幸TDD大量的自我裝載只會導致延遲驗收測試的時間,從而大大增加代價。在實際開發中,我提倡在第一次整合運行測試之前不要寫單元測試用例;自動化的驗收測試用例則視編寫和維護的代價而定,如果代價比較高,則應該採用文檔和Use
Case來描述需求,因為這兩種方式比自動化的驗收測試更容易維護。編寫單元測試一定是在整合以後,這樣才能首先得到外部反饋,盡量先保證做正確的事情,再正確地做事。
下面這段話來自於InfoQ文章《Mock不是測試的銀彈》:“在使用JMock架構後測試編寫起來更容易,運行速度更快,也更穩定,然而出乎意料的是產品品質並沒有如我們所預期的隨著不斷添加 的測試而變得愈加健壯,雖然產品代碼的單元測試覆蓋率超過了80%,然而在發布前進行全面測試時,常常發現嚴重的功能缺陷而不得不一輪輪的修複缺陷、迴歸 測試。為什麼編寫了大量的測試還會頻繁出現這些問題呢? ”這描述的情況和我在實踐中遇到的情況類似,不過很可惜文章並沒有找到問題真正的原因。真正的原因不是什麼Mock不Mock,而是TDD的單元測試是基於開發人員的假設,這些假設的測試即使全部通過程式碼涵蓋範圍100%,到了整合測試發現假設根本不成立或者原先在單元層面很多情況沒有考慮到,這又怎能保證高品質?在TDD的實踐者中我見到過不少類似這樣的,他們很認真,編寫了很多單元測試用例,程式碼涵蓋範圍也很高,但他們其實是有意無意在
先正確地做事(單元測試),再做正確的事(整合測試),這就是本末倒置。
當然,我不是全盤否定TDD。TDD在某些需求比較固定的場合是適用的,尤其是與具體業務關係不大的需求,比如:寫一個通用的資料結構,實現一個通用演算法。TDD的先關注需求和思考外部介面設計的理念也對促進開發人員的抽象思維有很大益處。另外,TDD通常也具有較高的程式碼涵蓋範圍。本文的主要觀點在於:實際項目中,由於使用者需求不確定性和外部環境不確定性,不要期望可以在實現之前完全明確需求,需求是在實際運行看到效果之後才逐步明確的;我們的開發過程必須能夠敏捷地適應需求的變化,而TDD的Test First理念恰好與之矛盾。所以,對於TDD不瞭解的朋友,我建議應該學習和實踐TDD,從而獲得其益處;同時我也提醒TDD存在理論上的缺陷,這是在實踐中需要特別留意的。