程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> 簡述Java測試的自定義斷言

簡述Java測試的自定義斷言

編輯:關於JAVA

對於測試來說,編寫斷言似乎很簡單:我們只需要對結果和預期進行比較,通常使用斷言方法進行判斷,例如測試框架提供的assertTrue()或者assertEquals()方法。然而,對於更復雜的測試場景,使用這些基礎的斷言驗證結果可能會顯得相當笨拙。

使用這些基礎斷言的主要問題是,底層細節掩蓋了測試本身,這是我們不希望看到的。在我看來,應該爭取讓這些測試使用業務語言來說話。

在本篇文章中,我將展示如何使用“匹配器類庫”(matcher library);來實現自定義斷言,從而提高測試代碼的可讀性和可維護性。

為了方便演示,我們假設有這樣一個任務:讓我們想象一下,我們需要為應用系統的報表模塊開發一個類,輸入兩個日期(開始日期和結束日期),這個類將給出這兩個日期之間所有的每小時間隔。然後使用這些間隔從數據庫查詢所需數據,並以直觀的圖表方式展現給最終用戶。

標准方法

我們先采用“標准”的方法來編寫斷言。我們以JUnit為例,當然你也可以使用TestNG。我們將使用像assertTrue()、assertNotNull()或assertSame()這樣的斷言方法。

下面展示了HourRangeTest類的其中一個測試方法。它非常簡單。首先調用getRanges()方法,得到兩個日期之間所有的每小時范圍。然後驗證返回的范圍是否正確。

private final static SimpleDateFormat SDF 
        = new SimpleDateFormat("yyyy-MM-dd HH:mm"); 
@Test 
public void shouldReturnHourlyRanges() throws ParseException { 
       // given 
       Date dateFrom = SDF.parse("2012-07-23 12:00"); 
       Date dateTo = SDF.parse("2012-07-23 15:00"); 
       // when 
       final List<range> ranges = HourlyRange.getRanges(dateFrom, dateTo);         
       // then 
       assertEquals(3, ranges.size()); 
       assertEquals(SDF.parse("2012-07-23 12:00").getTime(), ranges.get(0).getStart()); 
       assertEquals(SDF.parse("2012-07-23 13:00").getTime(), ranges.get(0).getEnd()); 
       assertEquals(SDF.parse("2012-07-23 13:00").getTime(), ranges.get(1).getStart()); 
       assertEquals(SDF.parse("2012-07-23 14:00").getTime(), ranges.get(1).getEnd()); 
       assertEquals(SDF.parse("2012-07-23 14:00").getTime(), ranges.get(2).getStart()); 
       assertEquals(SDF.parse("2012-07-23 15:00").getTime(), ranges.get(2).getEnd()); 
}

毫無疑問這是個有效的測試。然而,它有個嚴重的缺點。在//then後面有大量的重復代碼。顯然,它們是復制和粘貼的代碼,經驗告訴我,它們將不可避免地會產生錯誤。此外,如果我們寫更多類似的測試(我們肯定還要寫更多的測試來驗證HourlyRange類),同樣的斷言聲明將在每一個測試中不斷地重復。

過多的斷言和每個斷言的復雜性減弱了當前測試的可讀性。大量的底層噪音使我們無法快速准確地了解這些測試的核心場景。我們都知道,閱讀代碼的次數遠大於編寫的次數(我認為這同樣適用於測試代碼),所以我們理所當然地要想辦法提高其可讀性。

在我們重寫這些測試之前,我還想重點說一下它的另一個缺點,這與錯誤信息有關。例如,如果getRanges()方法返回的其中一個Range與預期不同,我們將得到類似這樣的信息:

org.junit.ComparisonFailure: 
Expected :1343044800000 
Actual :1343041200000

這些信息太不清晰,理應得到改善。

私有方法

那麼,我們究竟能做些什麼呢?好吧,最顯而易見的辦法是將斷言抽成一個私有方法:

private void assertThatRangeExists(List<Range> ranges, int rangeNb, 
                                   String start, String stop) throws ParseException { 
        assertEquals(ranges.get(rangeNb).getStart(), SDF.parse(start).getTime()); 
        assertEquals(ranges.get(rangeNb).getEnd(), SDF.parse(stop).getTime()); 
} 
@Test 
public void shouldReturnHourlyRanges() throws ParseException { 
       // given 
       Date dateFrom = SDF.parse("2012-07-23 12:00"); 
       Date dateTo = SDF.parse("2012-07-23 15:00"); 
       // when 
       final List<Range> ranges = HourlyRange.getRanges(dateFrom, dateTo); 
       // then 
       assertEquals(ranges.size(), 3); 
       assertThatRangeExists(ranges, 0, "2012-07-23 12:00", "2012-07-23 13:00"); 
       assertThatRangeExists(ranges, 1, "2012-07-23 13:00", "2012-07-23 14:00"); 
       assertThatRangeExists(ranges, 2, "2012-07-23 14:00", "2012-07-23 15:00"); 
}

這樣是不是好些?我會說是的。減少了重復代碼的數量,提高了可讀性,這當然是件好事。

這種方法的另一個優勢是,我們現在可以更容易地改善驗證失敗時的錯誤信息。因為斷言代碼被抽到了一個方法中,所以我們可以改善斷言,很容易地提供更可讀的錯誤信息。

為了更好地復用這些斷言方法,可以將它們放到測試類的基類中。

不過,我覺得我們也許能做得更好:使用私有方法也有缺點,隨著測試代碼的增長,很多測試方法都將使用這些私有方法,其缺點將更加明顯:

斷言方法的命名很難清晰反映其校驗的內容。

隨著需求的增長,這些方法將會趨向於接收更多的參數,以滿足更復雜檢查的要求。(assertThatRangeExists()現在有4個參數,已經太多了!)

有時候,為了在多個測試中復用這些代碼,會在這些方法中引入一些復雜邏輯(通常以布爾標志的形式校驗它們,或在某些特殊的情況下,忽略它們)。

從長遠來看,所有使用私有斷言方法編寫的測試,意味著在可讀性和可維護性方面將會遇到一些問題。我們來看一下另外一種沒有這些缺點的解決方案。

查看本欄目

匹配器類庫

在我們繼續之前,我們先來了解一些新工具。正如之前提到的,JUnit或者TestNG提供的斷言缺少足夠的靈活性。在Java世界,至少有兩個開源類庫能夠滿足我們的需求:AssertJ(FEST Fluent Assertions項目的一個分支)和 Hamcrest。我傾向於第一個,但這只是個人喜好。這兩個看起來都非常強大,都能讓你取得相似的效果。我更傾向於AssertJ的主要原因是它基於Fluent接口,而IDE能夠完美支持該接口。

集成AssertJ和JUnit或者TestNG非常簡單。你只要增加所需的import,停止使用測試框架提供的默認斷言方法,改用AssertJ提供的方法就可以了。

AssertJ提供了一些現成的非常有用的斷言。它們都使用相同的“模式”:先調用assertThat()方法,這是Assertions類的一個靜態方法。該方法接收被測試對象作為參數,為更多的驗證做好准備。之後是真正的斷言方法,每一個都用於校驗被測對象的各種屬性。我們來看一些例子:

assertThat(myDouble).isLessThanOrEqualTo(2.0d); 
 
assertThat(myListOfStrings).contains("a"); 
 
assertThat("some text") 
       .isNotEmpty() 
       .startsWith("some") 
       .hasLength(9);

從這能看出,AssertJ提供了比JUnit和TestNG豐富得多的斷言集合。就像最後一個assertThat("some text")例子顯示的,你甚至可以將它們串在一起。還有一個非常方便的事情是,你的IDE能夠根據被測對象的類型,自動為你提示可用的方法。舉例來說,對於一個double值,當你輸入“assertThat(myDouble).”,然後按下CTRL + SPACE(或者其它IDE提供的快捷鍵),IDE將為你顯示可用的方法列表,例如isEqualTo(expectedDouble)、isNegative()或isGreaterThan(otherDouble),所有這些都可用於double值的校驗。這的確是一個很酷的功能。

自定義斷言

擁有AssertJ或者Hamcrest提供的更強大的斷言集合的確很好,但對於HourRange類來說,這並不是我們真正想要的。匹配器類庫的另一個功能是允許你編寫自己的斷言。這些自定義斷言的行為將與AssertJ的默認斷言一樣,也就是說,你能夠把它們串在一起。這正是我們接下來要做的。

接下來我們將看到一個自定義斷言的示例實現,但現在讓我們先看看最終效果。這次我們將使用(我們自己的)RangeAssert類的assertThat()方法。

@Test 
public void shouldReturnHourlyRanges() throws ParseException { 
       // given 
       Date dateFrom = SDF.parse("2012-07-23 12:00"); 
       Date dateTo = SDF.parse("2012-07-23 15:00"); 
       // when 
       List<Range> ranges = HourlyRange.getRanges(dateFrom, dateTo); 
       // then 
       RangeAssert.assertThat(ranges) 
            .hasSize(3) 
            .isSortedAscending() 
            .hasRange("2012-07-23 12:00", "2012-07-23 13:00") 
            .hasRange("2012-07-23 13:00", "2012-07-23 14:00") 
            .hasRange("2012-07-23 14:00", "2012-07-23 15:00"); 
}

查看本欄目

即便是上面這麼小的一個例子,我們也能看出自定義斷言的一些優勢。首先要注意的是//then後面的代碼確實變少了,可讀性也更好了。

將自定義斷言應用於更大的代碼庫時,將顯現出其它優勢。當我們繼續使用自定義斷言時,我們將注意到:

可以很容易地復用它們。我們不強迫使用所有斷言,但對特定測試用例,我們可以只選擇那些重要的斷言。

特定領域語言屬於我們,也就是說,對於特定測試場景,我們可以根據自己的喜好改變它(例如,傳入Date對象,而不是字符串)。更重要的是這樣的改變不會影響到其它測試。

高可讀性。毫無疑問,因為斷言包括了很多小斷言方法,每一個都只關注校驗的很小的某個方面,因此可以為校驗方法取一個恰當的名字。

與私有斷言方法相比,自定義斷言的唯一不足是工作量要大一些。我們來看一下自定義斷言的代碼,它是否真的是一個很難的任務。

要創建自定義斷言,我們需要繼承AssertJ的AbstractAssert類或者其子類。如下所示,我們的RangeAssert繼承自AssertJ的ListAssert類。這很正常,因為我們的自定義斷言將校驗一個Range列表(List<Range>)。

每一個使用AssertJ的自定義斷言都會包含創建斷言對象、注入被測對象的代碼,然後可以使用更多的方法對其進行操作。如下面的代碼所示,構造方法和靜態assertThat()方法的參數都是List<Range>。

public class RangeAssert extends ListAssert<Range> { 
 
  protected RangeAssert(List<Range> ranges) { 
    super(ranges); 
  } 
 
  public static RangeAssert assertThat(List<Range> ranges) { 
    return new RangeAssert(ranges); 
  }

現在我們看看RangeAssert類的其余內容。hasRange()和isSortedAscending()方法(顯示在下一個代碼列表中)是自定義斷言方法的典型例子。它們具有以下共同點:

它們都先調用isNotNull()方法,檢查被測對象是否為null。確保這個校驗不會失敗並拋出NullPointerException異常消息。(這一步不是必須的,但建議有這一步)

它們都返回“this”(也就是自定義斷言類的對象,對應例子中RangeAssert類的對象)。這使得所有方法可以串在一起。

它們都使用AssertJ Assertions類(屬於AssertJ框架)提供的斷言方法執行校驗。

它們都使用“真實”的對象(由父類ListAssert提供),確保Range列表(List<Range>)被校驗。

private final static SimpleDateFormat SDF 
             = new SimpleDateFormat("yyyy-MM-dd HH:mm"); 
 
     public RangeAssert isSortedAscending() { 
            isNotNull(); 
            long start = 0; 
            for (int i = 0; i < actual.size(); i++) { 
                     Assertions.assertThat(start) 
                            .isLessThan(actual.get(i).getStart()); 
                     start = actual.get(i).getStart(); 
            } 
            return this; 
     } 
 
     public RangeAssert hasRange(String from, String to) throws ParseException { 
            isNotNull(); 
 
            Long dateFrom = SDF.parse(from).getTime(); 
            Long dateTo = SDF.parse(to).getTime(); 
 
            boolean found = false; 
            for (Range range : actual) { 
                   if (range.getStart() == dateFrom && range.getEnd() == dateTo) { 
                           found = true; 
                   } 
            } 
            Assertions 
                   .assertThat(found) 
                   .isTrue(); 
            return this; 
 
     } 
}

那麼錯誤信息呢?AssertJ讓我們可以很容易地添加錯誤信息。對於簡單的場景,例如值的比較,通常使用as()方法就足夠了,示例如下:

Assertions 
            .assertThat(actual.size()) 
            .as("number of ranges") 
            .isEqualTo(expectedSize);

正如你所見到的,as()只是AssertJ框架提供的另一個方法。當測試失敗時,它打印下面的信息,我們立即就能知道哪兒錯了:

org.junit.ComparisonFailure: [number of ranges] 
    Expected :4 
    Actual :3

有時候只知道被測對象的名字是不夠的,我們需要更多信息以了解到底發生了什麼。以hasRange()方法為例,當測試失敗時,如果能夠打印所有range就更好了。我們可以通過overridingErrorMessage()方法來實現這種效果:

public RangeAssert hasRange(String from, String to) throws ParseException { 
       ... 
       String errMsg = String.format("ranges\n%s\ndo not contain %s-%s",
                                    actual ,from, to); 
 
       ... 
       Assertions.assertThat(found) 
              .overridingErrorMessage(errMsg) 
              .isTrue(); 
       ... 
}

現在,當測試失敗時,我們能夠得到非常詳細的信息。它的內容取決於Range類的toString()方法。例如,它看起來可能是這樣的:

HourlyRange{Mon Jul 23 12:00:00 CEST 2012 to Mon Jul 23 13:00:00 CEST 2012}, 
HourlyRange{Mon Jul 23 13:00:00 CEST 2012 to Mon Jul 23 14:00:00 CEST 2012}, 
HourlyRange{Mon Jul 23 14:00:00 CEST 2012 to Mon Jul 23 15:00:00 CEST 2012}] 
do not contain 2012-07-23 16:00-2012-07-23 14:00

總結

在本文中,我們討論了很多編寫斷言的方法。我們從“傳統”的方式開始,也就是基於測試框架提供的斷言方法。對於很多場景,這已經非常好了。但是正如我們所看到的,它在表達測試意圖時,有時候缺少了一些靈活性。之後,我們通過引入私有斷言方法,取得了一點改善,但仍然不是理想的解決方案。最後,我們嘗試使用AssertJ編寫自定義斷言,我們的測試代碼取得了非常好的可讀性和可維護性。

如果要我提供一些關於斷言的建議,我將會建議以下內容:如果你停止使用測試框架(例如JUnit或TestNG)提供的斷言,改為使用匹配器類庫(例如AssertJ或者Hamcrest),你的測試代碼將得到極大的改善。你將可以使用大量可讀性很強的斷言,減少測試代碼中//then之後的復雜聲明。

盡管編寫自定義斷言的成本非常低,但也沒有必要因為你會寫就一定要使用它們。當你的測試代碼的可讀性並且/或者可維護性變差時使用它們。根據我的經驗,我會鼓勵你在以下場景中使用自定義斷言:

當你發現使用匹配器類庫提供的斷言無法清晰表達測試意圖時;

作為私有斷言方法的替代方案。

我的經驗告訴我,單元測試幾乎不需要自定義斷言。而在集成測試和端到端測試(功能測試)中,我敢說你肯定會發現它們是不可替代的。它們能讓你的測試用領域語言說話(而不是實現語言),它們還封裝了技術細節,使測試更易於更新。

查看本欄目

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved