寫給精明Java開發者的測試技巧

ImportNew發表於2015-07-29

我們都會為我們的程式碼編寫測試,不是嗎?毫無疑問,我知道這個問題的答案可能會從 “當然,但你知道怎樣才能避免寫測試嗎?” 到 “必須的!我愛測試 ”都有。接下來我會給你幾個小建議,它們可以讓你編寫測試變得更容易。那會幫助你減少脆弱的測試,並保證應用程式更加健壯。

與此同時,如果你的答案是 “不,我不編寫測試 ”,那麼我希望這些簡單但有效的技術可以讓你瞭解編寫測試帶來的好處。你也會看到,編寫一個複雜、沒有價值的測試集 (test suit)並沒有你認為的那麼難。

寫給精明Java開發者的測試技巧

如何編寫測試、有哪些用於管理測試集合的最佳實踐這些主題並不新鮮。我們在過去已經就這個問題的某些方面討論了很多次。從 “在構建過程中使用整合測試的正確方式” 到談論“在單元測試中恰當地模擬環境”, 再到“ 程式碼覆蓋率以及如何找到哪些是你真正需要測試的程式碼”。

但是,今天我想和你談論一系列小建議,這些建議可以幫助你在頭腦中理清測試自下而上是如何運作的。從如何構造一個簡單的單元測試到 對 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);
}

看看程式碼流多麼精準!計劃-執行-斷言模式可以讓你快速理解測試的功能。偏離了這個模式後會很容易寫出非常糟糕的程式碼。

牢記迪米特法則

迪米特法則在軟體上面應用了最小知識原則,減小了單元的耦合——這一直是在開發軟體的設計目標。

迪米特法則可以表述為一系列的規則:

  • 在方法中,一個類的例項可以呼叫該類的其它方法;
  • 在方法中,例項可以查詢自己的資料,但不能查詢資料的資料(譯者注:即例項的資料比較複雜時,不能進行巢狀查詢);
  • 當方法接收引數時,可以呼叫引數的第一級方法;
  • 當方法建立了一些區域性變數的例項後,這個類的例項可以呼叫這些區域性變數的方法;
  • 不要 呼叫全域性物件的方法。

那麼,就測試而言,這些意味著什麼呢?好吧,由於迪米特法則減少了應用程式各部分之間的耦合,這意味著測試應用程式中的各個部分變得更加容易。為了要檢視該法則如何為測試提供幫助,我們來看一個定義非常糟糕的類,它違背了迪米特法則:

考慮下面這個我們要測試的類:

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都是非常優秀的框架,它們提供了許多不同的方法在測試中對一個值進行斷言。例如,檢查兩個值是相同還是不同,或者值是否為空。

好,既然已經同意斷言很酷,那麼讓我們隨時隨地使用它們吧!等一下,過度使用斷言會使得測試變得脆弱,從而導致無法維護。一旦這樣,我們很清楚後面的結果是怎樣的——不能被測試和不穩定的程式碼。

考慮下面的測試示例:

@Test

public 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() );
}

乍一看,這段程式碼沒有什麼問題。我們遵循了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之間做一個平衡,這通常肯定會讓我們的測試變得更加容易維護。

結論

在這篇文章中,我介紹了五個基本原則,這些原則會幫助我們針對應用程式編寫單元測試。如果你有任何想法,歡迎通過下面的評論進行分享,或者你可以在Twitter上找到我:@cocoadavid

希望你能夠希望我們討論過的這些原則,並且能夠看到它們是如何潛移默化地讓你熱愛編寫單元測試。是的,我是說“熱愛”,因為我相信編寫單元測試是高品質軟體的基本要求。

高品質軟體意味著滿意的使用者,而滿意的使用者意味著幸福的開發人員。開發快樂!

相關文章