如何實現Java測試的自定義斷言
對于測試來說,編寫斷言似乎很簡單:我們只需要對結果和預期進行比較,通常使用斷言方法進行判斷,例如測試框架提供的assertTrue()或者assertEquals()方法。然而,對于更復雜的測試場景,使用這些基礎的斷言驗證結果可能會顯得相當笨拙。 使用這些基礎斷言的主要問題是,底層細節掩蓋了測試本身,這是我們不希望看到的。在我看來,應該爭取讓這些測試使用業務語言來說話。 在本篇文章中,我將展示如何使用“匹配器類庫”(matcher library);來實現自定義斷言,從而提高測試代碼的可讀性和可維護性。 為了方便演示,我們假設有這樣一個任務:讓我們想象一下,我們需要為應用系統的報表模塊開發一個類,輸入兩個日期(開始日期和結束日期),這個類將給出這兩個日期之間所有的每小時間隔。然后使用這些間隔從數據庫查詢所需數據,并以直觀的圖表方式展現給最終用戶。 標準方法 我們先采用“標準”的方法來編寫斷言。我們以JUnit為例,當然你也可以使用TestNG。我們將使用像assertTrue()、assertNotNull()或assertSame()這樣的斷言方法。 下面展示了HourRangeTest類的其中一個測試方法。它非常簡單。首先調用getRanges()方法,得到兩個日期之間所有的每小時范圍。然后驗證返回的范圍是否正確。private final static SimpleDateFormat SDF= new SimpleDateFormat("yyyy-MM-dd HH:mm");@Testpublic void shouldReturnHourlyRanges() throws ParseException {// givenDate dateFrom = SDF.parse("2012-07-23 12:00");Date dateTo = SDF.parse("2012-07-23 15:00");// whenfinal List<range> ranges = HourlyRange.getRanges(dateFrom, dateTo);// thenassertEquals(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());}@Testpublic void shouldReturnHourlyRanges() throws ParseException {// givenDate dateFrom = SDF.parse("2012-07-23 12:00");Date dateTo = SDF.parse("2012-07-23 15:00");// whenfinal List<Range> ranges = HourlyRange.getRanges(dateFrom, dateTo);// thenassertEquals(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個參數,已經太多了?。?/div> 有時候,為了在多個測試中復用這些代碼,會在這些方法中引入一些復雜邏輯(通常以布爾標志的形式校驗它們,或在某些特殊的情況下,忽略它們)。 從長遠來看,所有使用私有斷言方法編寫的測試,意味著在可讀性和可維護性方面將會遇到一些問題。我們來看一下另外一種沒有這些缺點的解決方案。 匹配器類庫 在我們繼續之前,我們先來了解一些新工具。正如之前提到的,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值的校驗。這的確是一個很酷的功能。
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()); } |
私有方法
那么,我們究竟能做些什么呢?好吧,最顯而易見的辦法是將斷言抽成一個私有方法:
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個參數,已經太多了?。?/div>
有時候,為了在多個測試中復用這些代碼,會在這些方法中引入一些復雜邏輯(通常以布爾標志的形式校驗它們,或在某些特殊的情況下,忽略它們)。
從長遠來看,所有使用私有斷言方法編寫的測試,意味著在可讀性和可維護性方面將會遇到一些問題。我們來看一下另外一種沒有這些缺點的解決方案。
匹配器類庫
在我們繼續之前,我們先來了解一些新工具。正如之前提到的,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值的校驗。這的確是一個很酷的功能。