測試驅動開發下的軟件生長

測試驅動開發下的軟件生長

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

關於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 測試代碼結構

儘管測試內容不同,大多數測試代碼都具有如下的基本結構:

  1. 準備:準備測試所需的上下文環境,包括依賴和輸入數據。
  2. 執行:執行目標方法(可能是多個),觸發被測試的行爲。
  3. 驗證:驗證被測試行爲產生的外部可見的效果,包括返回值和對依賴的調用。
  4. 清理:清理所有可能影響後續測試的狀態。

經過不斷地重構,最終測試代碼會逐漸分化成兩個層次:聲明層(Declarative layer)和實現層(Implementation layer)。前者在後者基礎上,通過各種語法糖,去除語言中的語法雜音,簡潔地描述要測試“什麼”。而實現層則是具體的實現邏輯。聲明層類似編譯器的前端,負責語言語法的解析,而實現層則類似解釋器去解釋執行。從這種角度來看,每個測試的聲明層都可以看作是一個迷你的領域特定語言(Domain-specific language,DSL)。

Interpreter

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 原則

下面就總結一下,提取出前面內容中最重要的原則:

  1. 系統骨架
    1. 儘可能早地確定系統骨架,實現集成測試自動化。
    2. 骨架要儘可能簡單,只包含最明顯的模塊,足以啓動持續集成即可。
  2. 驗收測試
    1. 新功能要以添加一個新的驗收測試開始。
    2. 新功能要以通過這個驗收測試作爲結束條件。
  3. 單元測試
    1. 測試驅動應從系統邊界向核心領域對象,逐步實現。
    2. 單元測試要從最簡單的成功用例開始,而不是異常用例。
    3. 開始編寫測試時忽略編譯錯誤,專注於可讀性。
    4. 開始開發前,仔細觀察失敗用例的錯誤消息。
    5. 要測試行爲,而不是方法。
    6. 測試的名字要描述被測試的功能。
    7. 測試數據的構建要儘可能簡潔。
    8. 用局部或者全局靜態變量命名常量。
    9. 斷言要儘可能“窄”、準確,避免重複。
    10. 除了斷言,還要有準確的期望。
    11. 只有當你要對異常內容做斷言時,纔去捕捉它。
    12. 最終測試代碼應由聲明層和實現層兩部分組成。
    13. 當前面任何一項難以施行或過度冗長時,思考是否需要重構被測試對象。

5.2 壞味道

最後,再列舉幾條測試代碼的壞味道:

  1. 測試名字沒有清晰地描述出被測試的功能,以及它與其他測試側重點的不同。
  2. 一個測試看起來在測試多個功能。
  3. 測試代碼沒有統一的結構,讀者無法快速得到每個測試的意圖。
  4. 測試裏有太多的測試數據構建和異常處理代碼,模糊了核心邏輯。
  5. 測試裏有很多硬編碼的常量,含義不明。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章