測試驅動開發下的軟件生長
1.前言
最近讀完了《Growing Object-Oriented Software, Guided by Tests》,這本在豆瓣上高達9.5分的好書。事實證明,羣衆的眼睛是雪亮的。除去中間那個很長的實際項目案例沒耐下心來看完,其他部分我都看了不止一遍。雖然還沒有讀過那本名氣很大的《Test Driven Development: By Example》,但到目前爲止,這本書已經成了我心中測試驅動開發的“聖經”。
讀完全書,印象深刻的地方實在太多了,比如快速反饋的重要性、軟件系統的動態視角、測試代碼的作用、單元測試應該是什麼樣子等等。除了方法論,書中關於好代碼的想法與之前讀完的另一本經典《Elegant Objects》也異曲同工,比如簡潔短小的類、窄接口、尊重對象的抽象、聲明式編程等等。甚至書中還講到一些“哲學”思考,比如軟件像有機物一樣生長而不是建造,傾聽你的測試看它到底需要什麼,要從對象間的關係網中動態地看軟件系統。
總體來說,這是一本程序員不可不讀,而且還要放在手邊反覆翻閱的好書。下面就來說說書中重點的內容,算是拋磚引玉了。
2.理論根基
2.1 高質量編程
軟件質量一般分外部和內部兩種,外部質量一般比較容易度量,因爲它更容易直觀的看到,比如對用戶要求的功能實現得好壞。而內部質量,也就是我們經常說的代碼質量,則抽象得多,比較難衡量的。一般最常說到的指標可能就是高內聚和低耦合。測試驅動開發(TDD)之所以這麼流行,正是因爲遵循它確實可以得到高質量的代碼。
具體來說好處有三點:1)從測試開始意味着你要先描述你要做什麼(What)而不是如何做(How),最終測試將作爲活文檔存在;2)讓一個類容易測試,意味着它要有合適的大小和職責,以及清晰的接口定義,即高內聚;3)測試一個類還意味着你要爲其初始化依賴,幫你做到低耦合。在TDD的過程中,伴隨着大量的“無情地”重構,不斷幫你發現新的接口抽象,提取出新的方法和類。
這裏順便提一個書中講到的重要的觀念轉變,就是從靜態的接口和類來看軟件系統,進階到從動態的對象關係中來看。作者提到這種運行時的關係聲明,在目前編程語言裏是欠缺的。正如書中所說,接口和對象只能告訴你這些類的對象能夠適配(fit),而它們是否能一起工作(work)得到想要的系統行爲,則要看運行時的通信。
2.2 快速反饋
TDD並不是簡單地把調整開發和測試的順序。它之所以好用並且流行起來,背後是有其哲學思想做支撐的。說起來有些玄,其實道理很簡單,這個思想就是“實踐出真知”。就好比物理學家做實驗來驗證其假設,我們程序員也通過實踐來驗證設計是否可行。
那可能很多人會說:編碼本身不就是實踐嗎?設計好了就按照概要和詳細設計文檔開發不就可以了嗎?這裏的關鍵在於:你能多快得到反饋,從而驗證你的想法是可行的。開發了一大半甚至最後收尾時,發現致命問題或者組合不起來,導致項目延期、返工甚至徹底失敗。你也得到了反饋,你也通過實踐得到了“真知”,可是代價太大了。那如何才能避免這樣的風險呢?答案就是遵循TDD的流程來開發,並且在每一步都執行最佳實踐。
3.總體流程
文章後面的這兩大部分,就從整體和細節上介紹一下TDD。首先,下面就是TDD的總體流程圖。這張圖是我在反反覆覆讀了這本書之後,將幾張散落的流程圖的合併得到的。
關於TDD循環的具體內容會在下一部分介紹,這裏先重點說說幾個大家可能比較感興趣的環節。
3.1 系統骨架
上面這個大循環開始的第一步就是要有一個整體的系統“骨架”(Skeleton),這樣才能把集成測試的設施準備好。爲了避免誤解,作者解釋道這並不是說要先有一個完整的設計(Big Design Up Front,BDUF),像傳統瀑布式模型一樣。這裏想說的是,你至少要知道自己要做什麼。所以一個黃金法則就是,“骨架”應該能在白板上花幾分鐘就畫出來,是整個系統最高、最“薄”的一層。
作者還建議如果條件允許,在一塊白板或者組內的網站上,動態維護一張系統的架構圖,讓大家對系統的理解都儘可能在一個平面上。看到這時我在思考,是否可以維護一個動態的、自動從代碼中頂層類生成的架構圖呢?
3.2 觀察失敗的測試
這是TDD循環中比較容易忽視的一環,就是寫好一個失敗的測試用例後,創建出空的接口和類。然後不要急着去實現功能,而是先觀察,看目前的錯誤消息是不是足夠提示你哪裏出錯了。比如入參對象的描述不夠清楚,斷言的失敗消息不明確等等。
提高錯誤消息的明確性一般有三種方式:1)斷言時手動附加一句消息;2)提取數據對象,並實現其自描述的方法,如Java裏的toString;3)擴展Hamcrest等框架。通常,我們可以先提取數據對象,不得不對裏面的具體屬性做斷言時(後面會講到要儘可能降低斷言的粒度),在硬編碼一句消息。Hamcrest這種好用的框架要熟悉,這樣能省去不少麻煩。
3.3 添加新功能的順序
即便遵循TDD去開發,切入的順序也是很重要的。添加新功能最大的忌諱就是直接針對核心的業務對象進行TDD。
正確的做法是從驗收測試開始,添加好後進入TDD開發循環。具體順序是,從系統的邊界開始,逐步向內,比如從API到Service到業務邏輯類。這就像水面上的泛起的漣漪一樣,從前到後,從外向內,逐漸實現這個功能所需的所有類。
4.最佳實踐
4.1 用例設計:測試行爲而不是方法!
這可能是在實際編碼方面,對我影響最大的一點了。以前我一直無法理解這句話,因爲覺得如果一個接口的幾個方法要配合起來使用的話,爲什麼不合並隱藏到一個接口方法之後呢?直到最近反思自己寫的一個單元測試才頓悟,關鍵問題是“時間差”。在一個測試場景裏,接口的幾個方法可能必須在不同的時間點調用纔行。
舉一個例子,數據庫的執行計劃,按照傳統教材裏的說法,每個運算符都應該是一個Iterable的類,並實現打開、關閉以及取下一條數據的方法。
Class TableScan {
Void open();
Row next();
Void close();
}
Class TableScanTest {
Void open();
Void openWithIOException();
Void fetchData();
Void close();
Void closeWithIOException();
}
初看之下,這個單元測試沒什麼大問題。而且每個方法的正常和異常情況都覆蓋到了,測試覆蓋率應該不錯。可它最大的問題就是測試的是方法而不是行爲,這樣的單元測試:1)無法看到動態的關係全圖,因爲它沒有一個完整的場景;2)無法充當類的文檔,因爲同樣的原因。
一個比較好的單元測試可能是這個樣子的,模擬了這個類的使用者是如何逐行獲取數據的:
class TableScanTest {
Void executePlanOfTableScan() {
TableScan plan = ...
Plan.open()
Row row = plan.next()...
Plan.close();
}
Void executePlanOfTableScanWithIOException() {
...
}
}
4.2 測試的可讀性
4.2.1 寫你願意讀的測試
當你開始寫測試代碼時,不要在意語法,忽略代碼的編譯錯誤,專注在以最簡潔和自然語言的方式(聲明層)表達出要測試什麼。反覆讀你的測試,直到你滿意爲止,再開始構建支撐其實現的代碼(實現層)。
4.2.2 抽象程度
測試代碼本質上與線上代碼正相反:測試代碼的輸入和輸出是具體的,但被測試對象的執行是抽象的。而線上代碼的輸入和輸出是未知的、抽象的,但如何執行卻是具體的。此外,測試的一個重要是展現出對象之間的關係圖。
這兩點也正對應前面所提的,針對方法測試導致的兩個問題。正因如此,好的單元測試應該清晰地展示測試輸入數據、期望結果,依賴對象的交互,同時弱化被測試對象的執行細節。
4.2.3 測試方法名
同時測試的名字也很有學問,要能清晰地描述出被測試的功能(Feature)。書中提到了一種叫做TestDox的命名方式。這裏有兩點要注意的:1)不要擔心方法名字過長,比如JUnit,運行時會利用反射調用它;2)想象每個測試方法名字的主語都是當前被測試對象。
下面幾個測試方法的名字,好壞一目瞭然:
@Test public void test1(), test2(), test3()...
@Test public void isReady(), add()...
@Test public holdsItemsInTheOrderTheyWereAdded()...
4.2.4 測試代碼結構
儘管測試內容不同,大多數測試代碼都具有如下的基本結構:
- 準備:準備測試所需的上下文環境,包括依賴和輸入數據。
- 執行:執行目標方法(可能是多個),觸發被測試的行爲。
- 驗證:驗證被測試行爲產生的外部可見的效果,包括返回值和對依賴的調用。
- 清理:清理所有可能影響後續測試的狀態。
經過不斷地重構,最終測試代碼會逐漸分化成兩個層次:聲明層(Declarative layer)和實現層(Implementation layer)。前者在後者基礎上,通過各種語法糖,去除語言中的語法雜音,簡潔地描述要測試“什麼”。而實現層則是具體的實現邏輯。聲明層類似編譯器的前端,負責語言語法的解析,而實現層則類似解釋器去解釋執行。從這種角度來看,每個測試的聲明層都可以看作是一個迷你的領域特定語言(Domain-specific language,DSL)。
4.3 測試數據準備
4.3.1 輸入
有時被測試對象要求的輸入對象會比較複雜,導致測試數據的構建也變得冗長,直接模糊了一個測試用例的用意。這時我們要想盡辦法簡化測試數據的構建,同時還不能讓其太抽象。設計模式中的Builder模式能幫我們大忙。
4.3.2 常量
此外,因爲前面講到測試代碼的具體性,所以不可避免地會出現很多數字、字符常量。一定要確保這些常量的含義是明確的,必要時將其提取爲局部變量或者全局的靜態變量。
4.4 斷言與期望
4.4.1 斷言
寫斷言(Assertions)經常犯的毛病就是一個方法的每個測試用例都很像,都直接斷言了整個返回值。這將會導致兩個問題,一是測試的目的不清晰,無法當成類的活文檔;二是難以定位錯誤和維護修改,因爲用例之間有太多重複,修改一點代碼就會導致很多測試失敗。
所以,我們要做到:1)避免去斷言返回結果中,不是由當前測試輸入驅動的部分;2)避免重複斷言其他測試中已經涵蓋的部分。其實這兩條做起來並不難,因爲通常情況下,返回結果是一個對象,我們只需對其中的某個或某幾個屬性斷言即可。
關於斷言的可讀性,Hamcrest應該就是最好的幫手了。雖然準備時會顯得代碼很多,因爲要擴展其Matcher,但最後寫出的斷言的確是非常漂亮,可讀性極高的描述式的語句。
4.4.2 期望
類似地,我們也要有準確的期望(Expectations),即依賴的外部對象會被如何調用,按照什麼順序調用,調用幾次,消息(參數)是什麼樣的。期望可能是最容易被忽視的,因爲像我Mock時經常會“偷懶”,入參全都匹配全部,執行後也不會驗證調用的其他信息。但期望恰恰是測試裏很重要的部分,別忘了我們前面說的,測試的一個重要作用就是當作文檔,明確運行時的對象關係。
最近發現Mockito不知道哪個版本開始,如果你mock了一樣東西,但是它並沒有被調用的話,它會讓測試失敗。要麼就是你的測試的確多mock了,要麼就是你的代碼有問題,有的地方沒有執行到。這實際上就是自動化了期望的驗證,對寫好測試還是很有幫助的。
4.5 傾聽你的測試
4.5.1 假如你是一個對象
當我們在不斷重構中發現新的接口時,要從對象的視角去想“我”到底需要什麼。以當前被測試對象作爲用戶,將自己代入到情境中去提取新的抽象,而不是從外面作爲測試它的人認爲它應該有什麼。
4.5.2 爲什麼難測
當你發現前面所講的任何一點,包括依賴、測試數據、斷言和期望等,要麼需要非常多的代碼,要麼就是很難測試。這時我們要做的不是一味地堆代碼,而是思考這個問題產生的原因是什麼。是被測試的類就應該這麼複雜,還是我們沒有做好高內聚和低耦合。這種反思其實也是通用的解決問題思路里的一環,即在定義問題後思考這是不是一個問題,要不要解決,有沒有方法繞過。
5.總結
5.1 原則
下面就總結一下,提取出前面內容中最重要的原則:
- 系統骨架
- 儘可能早地確定系統骨架,實現集成測試自動化。
- 骨架要儘可能簡單,只包含最明顯的模塊,足以啓動持續集成即可。
- 驗收測試
- 新功能要以添加一個新的驗收測試開始。
- 新功能要以通過這個驗收測試作爲結束條件。
- 單元測試
- 測試驅動應從系統邊界向核心領域對象,逐步實現。
- 單元測試要從最簡單的成功用例開始,而不是異常用例。
- 開始編寫測試時忽略編譯錯誤,專注於可讀性。
- 開始開發前,仔細觀察失敗用例的錯誤消息。
- 要測試行爲,而不是方法。
- 測試的名字要描述被測試的功能。
- 測試數據的構建要儘可能簡潔。
- 用局部或者全局靜態變量命名常量。
- 斷言要儘可能“窄”、準確,避免重複。
- 除了斷言,還要有準確的期望。
- 只有當你要對異常內容做斷言時,纔去捕捉它。
- 最終測試代碼應由聲明層和實現層兩部分組成。
- 當前面任何一項難以施行或過度冗長時,思考是否需要重構被測試對象。
5.2 壞味道
最後,再列舉幾條測試代碼的壞味道:
- 測試名字沒有清晰地描述出被測試的功能,以及它與其他測試側重點的不同。
- 一個測試看起來在測試多個功能。
- 測試代碼沒有統一的結構,讀者無法快速得到每個測試的意圖。
- 測試裏有太多的測試數據構建和異常處理代碼,模糊了核心邏輯。
- 測試裏有很多硬編碼的常量,含義不明。