用有效的測試培養工程——《Growing Object-Oriented Software, Guided by Tests》讀後感

愛飛翔發表於2011-12-15

  這本書2009年10月就出來了,當時沒來得及細看,只是把它放入了我的待讀列表中。後來查到2010年8月也出了中文版,書名叫《測試驅動的物件導向軟體開發》。看完全書後,我發現本書重點談的還是軟體培養問題。Growing這個詞出現在書的標題中,非常吸引我的思路。

  在前言中,作者開宗明義,講了本書要強調的五個問題:1.如何讓測試驅動開發適應我的工程;2.從那裡開始做TDD;3.如何寫單元測試與端對端測試;4.測試驅動開發的“驅動”是何意;5.如何測試某一個困難的功能。

  第一部分是簡介。導言部分說了,TDD不僅是XP(極限程式設計)的核心實踐,而且也被Crystal Clear,Scrum等敏捷開發方法才用。敏捷和非敏捷專案都可應用TDD,甚至是純研究專案都可以。

  第一章重申TDD的紀律——“在沒有一個失敗的用例之前,不要寫任何功能”。其後提出,將傳統的“測試-實現-重構”小迭代外面,包上一個驗收測試(Acceptance Test)。驗收測試是測試整個系統是否能工作的。與之區別的是用於測試現有程式碼與我們不能改變其程式碼的模組是否能配合工作的整合測試(Integration Test),以及測試物件本身功能的單元測試(Unit Test)。最後強調了單元測試能夠給我們一個發現不良設計以及重構程式碼的機會。

  接下來第二章講述了“值類”和“物件類”的區別。針對不變的數量和度量進行建模,就是“值類”,它有些類似“不可變類”(Immutable Class)或者無狀態(Stateless)類。如果針對行為進行建模,則是“物件類”了。最後講述了要使用單一的“告知型”呼叫代替請求式的一連串呼叫,例如用“master.allowSavingOfCustomisations();”來代替“((EditSaveCustomizer)master.getModelisable().getDockablePanel().getCustomizer()).getSaveItem().setEnabled(Boolean.FALSE.booleanValue());"。第三章介紹了一些TDD的基本工具。

  第二部分講述了TDD的具體過程。第四章講了如何確定測試驅動開發的第一個迭代週期。提出先實現最少量的真實功能以便可以自動地構建、部署、端對端測試。這叫做“行走骨架”(Walking Skeleton),具體的說,是通過“理解問題->設計草圖->自動化構建,部署,端對端測試->普通迭代”這個流程來做的。其後說道,測試先行的工程早起會引入混亂,但是迅速降低,因為找到了發展的方向,預見了可能發生的錯誤。相反,測試後行(或到釋出期限前才整合)的工程在最後會出現大量的混亂。這可以作為是否選取TDD的一個參考標準。而“行走骨架”的好處就是,能讓我們在仍有時間、預算和解決問題的意願時,去解決問題,而不至於到了釋出前才發現工程已經失控。

  第五章講了如何維護測試驅動週期。作者提出,將衡量進度的測試(針對新功能的驗收測試)和用以捕捉“功能破壞”(Regression)的測試(已有的驗收測試、整合測試、單元測試)分隔開來。“筆者注:廣義的迴歸測試即為了保證正在開發的新功能不破壞既有功能而寫的測試,狹義的迴歸測試專指前述的驗收測試。”此後提到兩個問題,一是不要著急寫單元測試,以免整合時發現功能不符合需要,二是要學會“傾聽測試”——難於測試的功能往往意味著設計需要改進。如不改進,如果功能增多,則該有問題的設計會更難於修改。

  第六章講述瞭如何在TDD中使用物件導向的風格。作者講述了Cockburn的“埠與介面卡”架構,業務領域的程式碼應該同技術設施,例如資料庫,UI等,分離開來。不要讓技術概念洩漏入應用程式模型。所以要通過介面將應用程式核心業務與每個技術領域橋接起來。這就是Eric Evans在《領域驅動設計》中所說的防腐層(Anticorruption Layer)。其後又講了封裝和資訊遮掩的區別。封裝主要是相對於“對端”(Peer)來說的,強調訪問只能通過API進行。而資訊遮掩主要是針對上層業務來說的,意在使得高層邏輯不需關注低層細節。封裝和資訊遮掩中的兩個常見問題即是通過API返回內部實現而產生的“別名”效果。以及沒能提供直接的API呼叫,導致客戶程式碼通過自己組合API來完成任務,就像前述的一連串呼叫那樣,暴露了過多細節。接下來講了達成“單一責任原則”的一個口訣:不用任何連詞(和,或,但)去描述物件。如果有從句,那麼應該按照從句,把大物件拆分成一個個互相合作的小物件。這對於我們檢視自己的設計很有幫助。作者將設計中一個物件的協作者,分為三種角色模板。即依賴其服務方能運轉的“依賴物”(Dependencies),用於通知事件而不關注其具體身份的“被通知物”(Notifications),以及利用其調整自身行為以符合系統需求之“調整物”(Adjustments)。其後作者講了“組合體物件”(筆者注:即組合各種物件來完成自身任務的功能模組,不同於設計模式中的組合體模式)所提供的API要比其各自部分的API總和簡單。它封裝了元件的存在及其內部互動,為其對端展示出一個更簡單的抽象介面。本章的最後講述了“環境獨立性”的重要。“環境獨立性”規則幫我們判斷一個物件是否隱藏的太多或者隱藏了錯誤的資訊。當執行環境變化時,環境獨立的物件是易於改變的。其執行大環境可以通過構造器(如果對於該物件是貫穿生命期的)或需要環境的方法(如果是瞬態的)傳入。

  第七章繼續講述如何達成物件導向的設計。首先講述TDD對OO設計的幫助:1.我們必須先描述我們要做什麼,而後才是怎麼做;2.為了使得單元測試易懂(由此變得可維護),我們必須限定它們的範圍(筆者注:如果單元測試過長或者setup階段太繁複,則意味著受測的那個大物件需要拆解);3.我們必須將其依賴物傳給它,這意味著我們必須知道它依賴的都是些什麼東西。再說了介面和協議的作用:介面描述了兩個元件是否互相適配,而協議則描述了他們是否能一起運轉。又講了測試可以幫助我們發現設計中的問題:一個雜亂或不清晰的測試暗示著我們暴露了太多實現,而且我們應該重新考量該物件及其臨接物件的責任分配。在講到值類和物件類的設計時,作者提出了三個技巧:打散(將一個大物件分割成一組互相協作的小物件)、剝離(定義一個物件所需的新服務,增加一個提供該服務的新物件)、捆綁(將相關物件藏入一個容器物件中)。最後在談到介面問題時,作者提出了兩個觀點。一個說道:針對某一個介面的實現而寫的Impl類是沒有意義的。如果實現類真的沒有一個好名字,那可能意味著介面的命名或者設計很糟。可能它因為有太多的責任而喪失了重點;也可能它是以實現的角度命名,而非以其在客戶程式碼的角度上;又或者它是一個值而非一個實體物件——這種不協調有時會在寫單元測試時呈現出來。(例如MyInteger和MyIntegerImpl這種介面分離就是很糟糕的)另一個說:應該根據需要合併或者拆分介面。在發現實現類的結構不清晰時,應考慮介面是否沒有側重點,需要拆解。

  第八章講如何在第三方程式碼之上構建自己的工程。作者建議寫一個介面卡物件層,其使用第三方API實現它們的介面。我們用有重點的整合測試去測試這些介面卡,以確認我們理解了第三方API是如何工作的。

  第三部分講了一個例子,用開發一個捕捉拍賣行情而自動出價的競拍器,來說明如何以測試為指導,去培養OO軟體開發。本部分跨越了十一章。在這個過程中,穿插著對前兩章所講原則的例項化運用。第十一章演示瞭如何用最少的程式碼搭建起來可以執行的端對端測試。在本例中,僅有一個測試用空殼伺服器,一個Swing視窗(最少的程式碼),主程式向“伺服器”傳送加入訊息,核實伺服器確實收到訊息;伺服器關閉競拍,視窗顯示失敗訊息,核實視窗確實顯示了失敗訊息(可執行的端對端測試)。第十二章對如何組織測試提出了小建議:將測試放在一個不同的包中。防止通過包級別的後門去測試,同時方便在IDE中瀏覽。第十五章講述了修改命名的重要性:重新命名程式碼中的若干功能,這是開發程式的一個重要部分,就像我們可以用已經寫出的程式碼來更好地理解結構應該如何發展一樣,我們也通過用已經修改過的名字去程式設計以更好地理解這些名字的意義。我們可以理解型別和方法名是如何互相配合起來工作的,以及概念是否清晰,這都會激發我們發現新的想法。如果一個功能的名字不對,唯一能做的明智事情就是改變它,以免過後閱讀程式碼的人花了數不清的時間也弄不清程式碼在幹什麼。第十七章說到靜態設計和動態設計的問題:重構非常關注於靜態結構(類與介面),以至於很容易忽略應用程式動態結構(例項與執行緒)。有時我們需要退一步考慮,去畫一個類似互動圖那樣的東西。其後的第十八章說道TDD應該和其它的建模手段結合起來使用,並且不要把其它的建模技巧當作一種目的,而是要理解它們,並且把它們當作支援與指導開發的一種手段。最後第十九章說我們必須知道如何漸進式的改變程式碼,尤其是使得程式碼結構良好,以至於我們可以根據需求的改變把程式碼帶我們想去的地方。

  第四部分標題“可持續的測試驅動開發”,意為教大家如何提高測試的質量,以便讓測試能夠更及時、更好地提供關於設計缺陷的反饋。第二十章講了幾個知識點。當我們在測試驅動開發的過程中提取一個介面,我們就必須想出一個名字來描述剛剛發現的關係。我們覺得這使我們深入思考領域問題,並梳理出可能會錯過的概念。只覆寫可見的方法(即保護的和公有的)。這個規則防止了僅僅為了測試能覆寫而暴露了內部方法。在談到模仿物件(Mock Object)時,作者說有兩個試探法可以決定一個類是不是像值類從而不值得去仿造它。第一,它的值是不可變的;第二,我們想不出來一個有意義的名字來描述把這個類當作介面之後,實現它的那個類。在對付膨脹的(引數過多的,過於複雜的)構造器時,作者提出可以提取出隱含的元件。其要尋找兩個條件:經常在類裡一起使用的引數;擁有相同生命期的引數。當我們剛好找到了這種情況時,就得努力想出一個好名字來解釋這個概念。一個做得好的設計,其標誌之一即是這種改變可以很容易整合進來。我們堅持依賴物應該通過構造器傳入,但是被通知物和調整物可以設定為預設值,稍後再行配置。當一個構造器太大了,並且我們不認為引數中隱含有一個新的型別時,我們可以用更多的預設值,僅在有特殊的測試用例時才覆蓋掉它們。在談到期望陳述時,作者說要避免太多的期望。如果我們有很多期望,要麼就是檢視測試一個過於龐大的單位,要麼就是鎖定了太多的物件交流行為。作者還談到了傾聽測試給我們帶來的四個好處:使領域知識區域性化;將物件間的關係抽象並命名成類;通過定義型別和角色而帶來更多的名字,就意味著帶來更多的領域資訊;與其提供資料,不如提供行為。本章最後總結說:測試驅動開發是低容忍度的。低質量的測試會使得開發速度變得非常慢,而且受測系統內部程式碼質量低的話,會導致測試的質量也跟著變低。

  第二十一章講了測試的可讀性問題。以下五種情況應當改進:1.測試名稱沒有清晰的描述出每個測試用例的重點,以及它和其餘測試用例的差別;單一的測試用例執行了多個功能;測試用例間的結構差異很大,以至於讀者不能通過速讀來理解它們的意圖;過多的設定和異常處理程式碼,將業務邏輯淹沒其中;測試使用了不明其意的字面值(魔法數字)。在講到如何命名測試時,作者介紹了TestDox約定法,即使每一個名字讀起來像一個句子,其隱含主語即為測試目標,例如:A List holds items in the order they were added. A List can hold multiple references to the same item. A List throws an exception when removing an item it doesn’t hold.即可翻譯成三個測試方法的名稱:holdsItemsInTheOrderTheyWereAdded(),canHoldMultipleReferencesToTheSameItem(),throwsAnExceptionWhenRemovingAnItemItDoesntHold()。在變數的命名上,作者強調我們應該用能夠顯示這些值或者物件在測試中所扮演的角色以及他們同目標物件的關係的名字來命名。

  第二十二章講了如何構建複雜的測試資料。測試資料構建器的一個好處是,我們可以寫出易於閱讀且便於發現錯誤的測試程式碼,因為每個構建器方法都指明瞭它的引數的意圖。

  第二十三章講述瞭如何從測試失敗資訊中演進工程程式碼。作者說,就算是發生在和我們現在所做無關的領域裡面,未預期的測試失敗,也可能是有價值的。因為它們揭示了程式碼中我們所未注意到的隱含關係。如果一個失敗的測試清楚的解釋了失敗的東西和原因,我們就可以快速的排查並修正程式碼。同時作者建議,“經常同原始碼庫同步——可以到隔幾分鐘就一次的頻度——以便一旦一個測試突然失敗了,你不需要花費多長時間就能撤銷最近的修改,並去嘗試另一個方法。……比起一直查錯,有時候回滾程式碼並以一個清晰的頭腦重新思考“如何去寫”,可能會更快。”

  第二十四章講了測試的靈活性。如果一個物件因為有太多的依賴物或者其依賴物是隱藏的,從而很難從它的環境中解耦,那麼當系統的某個偏遠角落改變了,測試就會失敗。

  最後的第五部分講述了一些高階話題,第二十五章講述了持久化。作者提出將影響持久化狀態的測試孤立開來。將執行持久話操作的測試和針對被持久話物件進行的測試分開來做。並且提及一個小技巧:不要以模式來命名類或者介面,它們與系統其它類之間的關係才是重要的。當它們的工作方式改變時,這樣做會使得名稱具有誤導性。第二十六章講述了單元測試和執行緒。作者提出,併發是一個系統級別的關注點,我們應該在需要執行併發任務的物件“功能物件”之外來操控它。最後第二十七章講述了測試非同步程式碼的問題。作者指出,一個測試可以有兩種方式觀察系統:取樣可被觀測的狀態或者監聽它發出的事件。同時還指出了非同步測試的一個注意點:測試可能會在系統之前執行以至於沒有測試任何有用的東西。這會造成貌似正確的結果:錯誤的程式碼看起來好像能正常執行。還提出,取樣測試與監聽事件測試的一個明顯區別是,輪詢可能會錯過被新近狀態所覆蓋掉的狀態更新。解決辦法是,可以查詢記錄。觸發一個刺激事件,並等系統狀態穩定再查詢。作者又提出,我們經常採用一個命名方案去區分同步與斷言。例如waitUntil()是等待某一個受測系統穩定(同步程式碼),而assertEventually()則是斷言某個事件最終會發生。本章最後作者講述了測試排期事件的問題。通過將事件排期機制從系統中解耦,可以使得系統的行為具有確定性從而更易測試事件。我們可以將事件的生成抽取成一個由外部驅動的共享服務。

  本書的跋很值得一讀。寫了整個jMock從構想,初創,演進到鞏固的過程。起初是為了方便測試某一個物件內部的功能機制是否如我們預期,後來逐漸把關注點從引數的值轉移到了物件之間的資訊溝通上。現在jMock已經成為一個單元測試和驗收測試中進行期望陳述與斷言的常用庫了。讀者有必要熟悉並掌握它。最後的兩個附錄講了jMock2庫和Hamcrest匹配器的使用方法,如果對書前面的範例程式碼中的用法不太熟悉,可以參考。

  總的說來,在讀此書的過程中我非常驚喜,發現儘管TDD有很多令人詬病的缺點,但是仍然有人和我想像一樣,用不瘟不火的穩健心態來創造性的加入“培養”要素,以使得TDD對工程開發有更大的促進作用。測試是一個良好的反饋來源,可以真實的反映出我們在設計中考慮不周到之處,以及時督促我們改進產品程式碼的設計。要想讓測試能夠如此培養軟體的開發,就必須著力於測試程式碼的先行性、正確性、可讀性與靈活性。同時要注意用驗收測試來催生新的迭代週期,在修改程式碼時不僅要執行單元測試,更要及時執行驗收測試以獲得更多回饋。我想信,用心於測試的努力,必能在產品程式碼的研發中產生加倍的回報。

本文為原創,如需轉載請聯絡作者(Email eastarstormlee@gmail.com 微博 http://weibo.com/eastarlee

相關文章