寫給精明Java開發者的測試技巧
我們都會為我們的代碼編寫測試,不是嗎?毫無疑問,我知道這個問題的答案可能會從 “當然,但你知道怎樣才能避免寫測試嗎?” 到 “必須的!我愛測試 ”都有。接下來我會給你幾個小建議,它們可以讓你編寫測試變得更容易。那會幫助你減少脆弱的測試,并保證應用程序更加健壯。
與此同時,如果你的答案是 “不,我不編寫測試 。 ”,那么我希望這些簡單但有效的技術可以讓你了解編寫測試帶來的好處。你也會看到,編寫一個復雜、沒有價值的測試集 (test suit)并沒有你認為的那么難。
如何編寫測試、有哪些用于管理測試集合的最佳實踐這些主題并不新鮮。我們在過去已經就這個問題的某些方面討論了很多次。從 “在構建過程中使用集成測試的正確方式” 到談論“在單元測試中恰當地模擬環境”, 再到“ 代碼覆蓋率以及如何找到哪些是你真正需要測試的代碼”。
但是,今天我想和你談論一系列小建議,這些建議可以幫助你在頭腦中理清測試自下而上是如何運作的。從如何構造一個簡單的單元測試到 對 mock(模擬) 和 spy(監視) 以及復制粘貼測試代碼更高層次的理解。那我們開始吧。
AAArrr,像海盜一樣說話?
和大部分軟件開發一樣, 模式通常都是一個不錯的開始。無論是想要通過工廠來創建對象,或者希望將web應用程序中的關注點分散到Model、View和Controller中,在它們背后通常都會有一個模式,幫助你理解正在發生什么并解決困難。 那么,一個典型的測試看上去應該是怎么樣的?
當我們編寫測試時,其中一個最有用但卻極其簡單的模式是計劃-執行-斷言(Arrange-Act-Assert),簡稱AAA。
這個模式的前提是所有測試都應該遵循默認布局。測試系統所必需的全部條件和輸入都應該在測試方法開始的時候被設置(Arrange)。在計劃好所有前置條件后,我們通過觸發一個方法或者檢查系統的某些狀態的方式,在測試系統上運行(Act)。最后,我們需要斷言(Assert)測試系統是否已經生成了期望的結果。
讓我們來看一個Java JUnit測試的示例,它展示了這種模式:
@Test public void testAddition() { // Arrange Calculator calculator = new Calculator();// Act int result = calculator.add(1, 2); // Assert assertEquals("Calculator.add returns invalid result", 3, result);
}</pre>
看看代碼流多么精準!計劃-執行-斷言模式可以讓你快速理解測試的功能。偏離了這個模式后會很容易寫出非常糟糕的代碼。
牢記迪米特法則
迪米特法則在軟件上面應用了最小知識原則,減小了單元的耦合——這一直是在開發軟件的設計目標。
迪米特法則可以表述為一系列的規則:
- 在方法中,一個類的實例可以調用該類的其它方法;
- 在方法中,實例可以查詢自己的數據,但不能查詢數據的數據(譯者注:即實例的數據比較復雜時,不能進行嵌套查詢);
- 當方法接收參數時,可以調用參數的第一級方法;
- 當方法創建了一些局部變量的實例后,這個類的實例可以調用這些局部變量的方法;
- 不要 調用全局對象的方法。 </ul>
那么,就測試而言,這些意味著什么呢?好吧,由于迪米特法則減少了應用程序各部分之間的耦合,這意味著測試應用程序中的各個部分變得更加容易。為了要查看該法則如何為測試提供幫助,我們來看一個定義非常糟糕的類,它違背了迪米特法則:
考慮下面這個我們要測試的類:
public class Foo() { public Bar doSomething(Baz aParameter) { Bar bar = null; if (aParameter.getValue().isValid()) { aParameter.getThing().increment(); bar = BarManager.getBar(new Thing()); } return bar; } }
如果我們試著去測試這個方法,很快就會發現一些問題。這些問題是由于定義方法的方式導致的。
我們在測試這個方法時會遇到的第一個困難是,我們調用了一個靜態方法——BarManager.getBar()。我們沒有辦法在單元測試中簡單指定如何操作這個方法。還記得我們提過的計劃-執行-斷言模式嗎?但在這里,在通過調用 doSomething() 執行這個方法之前,我們沒有一種簡單的方式來設置 BarManager。如果 BarManager.getBar() 不是一個靜態方法,那么可以向 doSomething() 方法中傳入一個 BarManager 實例。在測試集中,傳遞一個樣本值(sample value)是非常容易的,并且我們也可以更好地控制和預測方法的執行過程。
我們還可以看到,在這個示例方法中調用了方法鏈:aParameter.getValue().isValid() 和 aParameter.getThing().increment()。為了測試它們,我們需要明確地知道aParameter.getValue() 和 aParameter.getThing() 的返回結果類型,然后才可以在測試中構建恰當的模擬值。
如果要做這些,那么我們不得不去了解這些方法返回對象的詳細信息。而我們的單元測試就會開始變形,逐漸成為一大堆不能維護的、脆弱的代碼。我們正在破壞單元測試中一個基本規則:只測試單獨的單元,而不是這個單元的實現細節。
我并不是在說單元測試只能測試單獨的類。然而在大多數情況下,把類作為一個單獨的單元考慮,可能是一個好主意。但是有些情況下,我們會將兩個或者更多的類看做是一個單元。
在這里我為各位讀者留下一個練習:對這個方法進行完全重構,使其更容易被測試。但對于新手來說,我們可能會將 aParameter.getValue() 對象作為一個參數傳遞給這個方法。這樣會滿足一些規則,提升方法的可測試性。
了解何時使用斷言
對于編寫應用程序測試來說,JUnit和TestNG都是非常優秀的框架,它們提供了許多不同的方法在測試中對一個值進行斷言。例如,檢查兩個值是相同還是不同,或者值是否為空。
好,既然已經同意斷言很酷,那么讓我們隨時隨地使用它們吧!等一下,過度使用斷言會使得測試變得脆弱,從而導致無法維護。一旦這樣,我們很清楚后面的結果是怎樣的——不能被測試和不穩定的代碼。
考慮下面的測試示例:
@Testpublic void testFoo { // Arrange Foo foo = new Foo(); double result = …;
// Act double value = foo.bar( 100.0 ); // Assert assertEquals(value, result); assertNotNull( foo.getBar() ); assertTrue( foo.isValid() );
}</pre>
乍一看,這段代碼沒有什么問題。我們遵循了AAA模式,并斷言了一些發生了的事情——那么哪里錯了?
首先,我們看到這個測試的名字:testFoo,它并沒有真正告訴我們這個測試在做什么事情,并且沒有匹配任何一個我們在檢查的斷言。
然后,如果其中一個斷言失敗了,我們能夠確定測試系統中的哪部分失敗了嗎?是 foo.bar(100.0) 方法失敗了?還是 foo.getBar() 或者 foo.isValid() 方法失敗了?如果不通過測試內部調試來試著找出到底發生了什么,我們是無從知道的。
單云測試的目的在于,我們想要一個可信賴的、健壯的測試集 。通過快速運行它們,我們可以知道應用程序的狀態。而示例中的產生的這種麻煩,已經使得我們的目的落空。如果測試失敗,我們不得不運行調試器來找到到底什么地方失敗了,那么我們的處境也會變得困難。
通常來說,一種最佳實踐是在一個特定的測試中,只有一個最合適的斷言。這樣我們可以確保測試是明確地,目標是應用程序的單個功能點。
Spy、Mock和Stub,天哪!
有時,Spy應用程序在做什么,或者驗證程序使用特定參數調用了特定方法并調用了指定次數,是很有用的。有時,我們想觸發數據庫層,但又想模擬數據庫返回給我們的響應。在Spy、Mock和Stub的幫助下,我們可以實現所有這些功能。
在Java中,我們有很多不同的庫,可以用來Spy、Mock和Stub,例如Mockito、EasyMock和JMockit。那么Spy、Mock和Stub之間有什么區別?我們應該在何時使用它們呢?
Spy可以讓你很容易檢查程序是否使用正確的參數調用了某些方法,并且會記錄這些參數以供后面的驗證使用。例如,如果你在代碼中有一個循環,在每次循環中會觸發一個方法,那么Spy可以用來驗證該方法被觸發的次數是正確的,并且每次觸發時都使用了正確的傳入參數。對于某些特定類型的存根來說,Spy是至關重要的。
Stub(存根)是一個對象,它可以在客戶端觸發某種請求時,提供特定的已經存儲的響應,例如,針對輸入存根已經有通過預編程生成的響應。當你想在代碼片段中強行設定某些條件時,存根會很有用,例如,如果數據庫調用失敗,而你希望在測試中觸發數據庫異常處理。存根是模擬對象個一個特例。
Mock(模擬)對象提供了存根對象的所有功能,而且它還提供了預編程的期望結果。這就是說模擬對象和真實對象非常接近,它可以根據之前設定的狀態來執行不同的行為。例如,我們可以用模擬對象來表示一個安全系統,它根據登錄的不同用戶,提供不同的訪問控制。就我們的測試而言,它會和一個真實的安全系統交互,而我們可以在應用程序中測試很多不同的路徑。
有時,我們會使用Test Double(測試替身)一詞來表示如上所述的任何類型的對象,我們在測試中會和這些對象進行交互。
通常來說,spy提供了最少的功能,因為它的目的就在于捕捉方法是否被調用。如果被調用,傳入的是什么參數。
Stub是下一個級別的測試替身,它通過設置預定義的方法調用返回值的方式,來設定測試系統的執行流程。一個特定的存根對象通常可以在很多測試中使用。
最后,mock object(模擬對象)提供了遠比比存根對象更多的行為。就這一點而言,一種最佳實踐是針對特定測試開發特定存根對象,否則存根對象就會想真實對象那樣開始變得復雜。
不要讓你的測試 過度DRY
在軟件開發過程中,通常讓你的應用程序DRY(不要重復自己,Don’t Repeat Yourself)是一種最佳實踐。
在測試中,情況并不總是這樣。當編寫軟件時,一種最佳實踐是重構那些通用的代碼片段,將其放入單獨的方法中,那么這些方法就可以在代碼中被調用很多次。這樣做很有意義,因為我們只編寫一次代碼,然后也只需要測試一次。另外,如果我們只需要將代碼片段編寫一次,我們也可以避免由于編寫很多次帶來的拼寫錯誤。要當心復制粘貼!
2006年,Jay Fields創造了一個新詞:DAMP(Descriptive And Meaningful Phrases,描述性和有意義的短語),它用來指代那些設計良好的領域特定語言。如果你想再次回憶,可以參考最初的郵件:DRY code, DAMP DSL。
DAMP背后的原理是這樣的,對于一個好的領域特定語言來說,它會使用描述性和有意義的短語來增加語言的可讀性,并降低高效使用該語言所需要的學習和培訓時間。
通常,在一個測試集中的許多單元測試可能都非常類似,唯一的微小區別就在于如何針對測試準備測試系統。因此,對于軟件開發人員來說,將這些重復的代碼從單元測試重構到幫助函數中是很自然的。同樣將實例變量重構成靜態變量也是很自然的,這樣它們就可以只針對每一個測試類聲明一次——再一次從測試中移除重復代碼。
盡管在做出如上重構后,代碼會變得更加“整潔”,但這些單元測試作為一個單獨的部分會變得更難讀懂。如果一個單元測試調用了其它幾個方法,并且在使用非局部變量,那么單元測試的流程就變得不直觀,并且你也不能夠像之前那樣容易理解單元測試的基本流程。
至關重要的是,如果我們讓我們的單元測試DRY,那么測試的復雜度反而會變得更高,而測試的維護工作也會變得更加困難——這正好和讓測試DRY的初衷相違背。對于單元測試來說,讓它們更DAMP、而不是DRY,這會增加測試的可讀性和可維護性。
關于應該在多大程度上重構你的測試,我們并沒有正確或者錯誤的答案,但我們要努力在讓測試過于DRY和過于DAMP之間做一個平衡,這通常肯定會讓我們的測試變得更加容易維護。
結論
在這篇文章中,我介紹了五個基本原則,這些原則會幫助我們針對應用程序編寫單元測試。如果你有任何想法,歡迎通過下面的評論進行分享,或者你可以在推ter上找到我:@cocoadavid。
希望你能夠希望我們討論過的這些原則,并且能夠看到它們是如何潛移默化地讓你熱愛編寫單元測試。是的,我是說“熱愛”,因為我相信編寫單元測試是高品質軟件的基本要求。
高品質軟件意味著滿意的用戶,而滿意的用戶意味著幸福的開發人員。
開發快樂!
原文鏈接: zeroturnaround 翻譯: ImportNew.com - Wing
譯文鏈接: http://www.importnew.com/16392.html