對於測試來說,編寫斷言似乎很簡單:我們只需要對結果和預期進行比較,通常使用斷言方法進行判斷,例如測試框架提供的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之後的復雜聲明。
盡管編寫自定義斷言的成本非常低,但也沒有必要因為你會寫就一定要使用它們。當你的測試代碼的可讀性並且/或者可維護性變差時使用它們。根據我的經驗,我會鼓勵你在以下場景中使用自定義斷言:
當你發現使用匹配器類庫提供的斷言無法清晰表達測試意圖時;
作為私有斷言方法的替代方案。
我的經驗告訴我,單元測試幾乎不需要自定義斷言。而在集成測試和端到端測試(功能測試)中,我敢說你肯定會發現它們是不可替代的。它們能讓你的測試用領域語言說話(而不是實現語言),它們還封裝了技術細節,使測試更易於更新。
查看本欄目