over 1 year ago

Translated from "Part 1: A First Look at JUnit 5", Mert Çalişkan, Java Magazine November/December 2016, page 14. Copyright Oracle Corporation.

Part 1: Unit 5 初探

長久期待的釋出,JUnit 5 全新的設計並有許多有用的新功能

廣為使用的 Java 單元測試框架,JUnit,在第四版釋出十年後,看到第五版的第一個 alpha 版釋出,JUnit 5 包含以模組架構重寫的程式碼、新的註釋 (annotation) 集、有利於第三方函式庫整合的可擴充模型、及能用 lambda 表示法寫驗證的能力。

JUnit 5 的前身是 JUnit Lambda 計畫,為下個世代的單元測試播下第一個想法的種子,在 2015 十月前於 Indiegogo 上透過群眾募資,籌得預期目標兩倍還多的貢獻。

經過這些年,JUnit 捕捉到單元測試框架的本質應該是什麼,但是,它的核心仍然原封不動,讓它難以進化,新的版本重寫整個產品,致力於為執行測試與產生報告提供充足與穩定的 API,用 JUnit 5 實作單元測試最低要求為 Java 8,但它可以執行用 Java 先前版本寫的測試。

本文第一個部分,我以詳細的例子描述 JUnit 5 主要的新特性,JUnit 團隊計劃在 2016 年底釋出此框架的最終版本,Milestone 2 是 JUnit 5 正式釋出前的最後幾步,這將勢必成為 Java 生態系最重要的釋出之一。在第二部分 [譯註:下一篇文章],我解釋如何用您既有的工具使用與配置 JUnit 5,及如何一起執行 JUnit 4 與 JUnit 5 的測試。

解剖 JUnit 測試

我們來看些 JUnit 5 的測試,先從 Listing 1 簡單的 JUnit 測試開始:

Listing 1.
import org.junit.jupiter.api.*;

class SimpleTest {

    @Test
    void simpleTestIsPassing() {
        org.junit.jupiter.api.Assertions.assertTrue(true);
    }
}

如上所示,乍看之下,一個簡單的 JUnit 5 測試類別,與 JUnit 4 測試類別相比,幾乎沒有不同,主要的差別是測試類別與測試函式不再需要宣告為 public,此外,@Test 以及接下來的註釋都移到名為 org.junit.jupiter.api 的新套件,需要匯入它。

利用註釋的威力

JUnit 5 提供改良過的註釋,以我的觀點,提供實作測試不可或缺的功能,這些註釋可單獨宣告,或混合使用建立客制的註釋。接下來幾節,以例子個別描述每個註釋。

@DisplayName 現在可以為測試類別或測試函式用 @DisplayName 命名,如 Listing 2 所示,描述可以包含空白與特殊字元,甚至可以有表情符號,例如 😃

Listing 2.
@DisplayName("This is my awesome test class 😃")
class SimpleNamedTest {

    @DisplayName("This is my lonely test method")
    @Test
    void simpleTestIsPassing() {
        assertTrue(true);
    }
}

@Disabled 和 JUnit 4 的 @Ignore 類似,@Disabled 可以關閉一整個測試類別或部分測試函式的執行,關閉測試的理由可以加在註釋的描述裡,如 Listing 3 所示:

Listing 3.
class DisabledTest {

    @Test
    @Disabled("test is skipped")
    void skippedTest() {
        fail("feature not implemented yet");
    }
}

@Tags@Tag 現在可以為測試類別、測試函式或兩者貼上標籤,標籤可以在執行時過濾測試,這功能與 JUnit 4 的 Categories 類似,Listing 4 是為測試類別貼上標籤的例子。

Listing 4.
@Tag("marvelous-test")
@Tags({@Tag("fantastic-test"), @Tag("awesome-test")})
class TagTest {

    @Test
    void normalTest() {
    }

    @Test
    @Tag("fast-test")
    void fastTest() {
    }
}

您可以提供標籤名稱給測試執行器,過濾出要執行或排除的測試,執行 ConsoleLauncher 的方法會在本文的第二部分 [譯註:下一篇] 描述,使用 ConsoleLauncher,可以搭配 -t 參數提供要執行的標籤名稱,或 -T 參數排除標籤名稱。

@BeforeAll@BeforeEach@AfterEach@AfterAll 這些註釋分別與 JUnit 4 的 @BeforeClass@Before@After@AfterClass 的行為完全一樣,被加註 @BeforeEach 的函式會在每個 @Test 函式前執行,而加註 @AfterEach 的函式會在每個 @Test 函式後執行。加註 @BeforeAll@AfterAll 的函式則會在執行所有 @Test 函式前與之後執行,這四個註釋將套用在加註 @Test 函式所在的類別,如果有類別繼承,同樣套用 (測試階層稍後討論),加註 @BeforeAll@AfterAll 的函式必須宣告成 static。

@Nested 測試階層 JUnit 5 支援建立測試階層,在彼此的內部建立巢狀的測試,這選項讓您能依邏輯組織測試,讓測試有相同的環境 [譯註:原文是 parent,但暫時想不到更好的中文],簡化為每個測試套用相同的初始化函式,Listing 5 是測試階層的一個例子。

Listing 5.
class NestedTest {

    private Queue<String> items;

    @BeforeEach
    void setup() {
        items = new LinkedList<>();
    }

    @Test
    void isEmpty() {
        assertTrue(items.isEmpty());
    }

    @Nested
    class WhenEmpty {

        @Test
        public void removeShouldThrowException() {
            expectThrows(NoSuchElementException.class, items::remove);
        }
    }

    @Nested
    class WhenWithOneElement {

        @Test
        void addingOneElementShouldIncreaseSize() {
            items.add("Item");
            assertEquals(items.size(), 1);
        }
    }
}

驗證與假設

JUnit 5 的 org.junit.jupiter.api.Assertions 類別包含靜態驗證函式,例如 assertEqualsassertTrueassertNullassertSame,以及對應的反向版本,以處理測試中的各種條件,JUnit 5 善用 lambda 表示式,在這些驗證函式提供多載版本,可接受 java.util.function.Supplier 的實體作為參數,延遲驗證訊息的計算,意味著潛在複雜的計算可以延遲到驗證失敗時才執行,Listing 6 是在驗證中使用 lambda 表示式的例子。

Listing 6.
class AssertionsTest {

    @Test
    void assertionShouldBeTrue() {
        assertEquals(2 == 2, true);
    }

    @Test
    void assertionShouldBeTrueWithLambda() {
        assertEquals(3 == 2, true, () -> 3 not equal to 2!);
    }
}

org.junit.jupiter.api.Assumptions 類別提供 assumeTrueassumeFalseassumingThat 靜態函式,如同文件所描述的,這些函式用來描述讓測試有意義的條件假設時相當有用。如果假設不成真,這不代表程式壞掉了,只是代表測試無法提供有用的資訊,預設的 JUnit 執行器會忽略這類失敗的測試,這讓系列中的其他測試能繼續執行。

群組的驗證

現在可以將一連串的驗證群組在一起,如 Listing 7 所示,用 assertAll 靜態函式讓所有的驗證一起被執行,全部的失敗一起被回報。

Listing 7.
class GroupedAssertionsTest {

    @Test
    void groupedAssertionsAreValid() {
        assertAll(
            () -> assertTrue(true),
            () -> assertFalse(false)
        );
    }
}

如果假設不成真,這不代表程式壞掉了,只是代表測試無法提供有用的資訊,預設的 JUnit 執行器會忽略這類失敗的測試,這讓系列中的其他測試能繼續執行。

期待意外

JUnit 4 提供一種方式處理例外,將例外宣告成 @Test 註釋的屬性,與前個版本需要 try-catch 區塊補捉例外相比,已經進步了,JUnit 5 引入 lambda 表示式處理在驗證述句中拋出的例外,Listing 8 展示例外直接放在驗證中。

Listing 8.
class ExceptionsTest {

    @Test
    void expectingArithmeticException() {
        assertThrows(ArithmeticException.class, () -> divideByZero());
    }

    int divideByZero() {
        return 3 / 0;
    }
}

使用 JUnit 5,可以將拋出的例外指派給某個變數,以驗證其值,如 Listing 9 所示。

Listing 9.
class Exceptions2Test {

    @Test
    void expectingArithmeticException() {
        StringIndexOutOfBoundsException exception = expectThrows(
            StringIndexOutOfBoundsException.class,
            () -> "JUnit5 Rocks!".substring(-1)
        );
        assertEquals(exception.getMessage(), "String index out of range: -1");
    }
}

動態測試

有 JUnit 5 的動態測試新功能,現在有機會在執行期建立測試案例,這在第五版之前是不可能的,因為所有的測試程式必須在編譯期就被定義,Listing 10 是動態建立測試的例子。

Listing 10.
class DynamicTestingTest {

    @TestFactory
    List<DynamicTest> createDynamicTestsReturnAsCollection() {
        return Arrays.asList(
            dynamicTest("A dynamic test", () -> assertTrue(true)),
            dynamicTest("Another dynamic test", () -> assertEquals(6, 3 * 2))
        );
    }
}

要建立動態測試,首先我在類別中建立一個函式並加註 @TestFactory,JUnit 分析類別時會處理這個 @TestFactory 函式,用它的回傳值動態建立測試單元,@TestFactory 加註的函式必須回傳內容為 DynamicTestCollectionStreamIterableIterator 實體,DynamicTest 類別代表在執行期中產生的測試案例,實際上,它是一個包裝類別,包含一個名字與一個可執行實體,執行實體指向測試程式,dynamicTest 靜態函式宣告在 DynamicTest 類別中,其目的是用接收到的名字與 Executable 實體 (如 Listing 10 所示,執行兩個驗證的 lambda 表示式) 建立 DynamicTest 的實體。

動態測試的生命週期與標準的 @Test 加註函式不同,這意味著生命週期的回呼函式,例如 @BeforeEach@AfterEach 不適用在動態測試上。

JUnit 團隊成功提供一個新的、重新設計的 JUnit 版本,解決前個版本中幾乎所有的限制。

參數化的測試函式

有 JUnit 5 動態測試的協助,可以用不同的資料執行相同的測試,這在 JUnit 4 同樣可以用 Parameterized 執行器做到,用 @Parameterized.Parameters 註釋定義測試資料,但這方法有個限制,它會為每個參數執行所有加註 @Test 的測試函式,一次又一次,導致不需要的執行。為每筆資料建立動態測試能提供較好的封裝,侷限在該測試函式上,我在 Listing 11 說明它。

Listing 11.
@TestFactory
Stream<DynamicTest> dynamicSquareRootTest() {
    return Stream.of(new Object[][] {{2d, 4d}, {3d, 9d}, {4d, 16d}})
        .map(i -> dynamicTest("Square root test", () -> {
            assertEquals(i[0], Math.sqrt((double)i[1]));
        })
    );
}

@TestFactory 加註的 dynamicSquareRootTest 函式不是一個測試案例,但它建立一個 Stream 實體,內容是 DynamicTest 類別,能包含不同測試的實作。為 stream 中每個元素組合,我執行一個 lambda 表示式,將資料對應到可執行的測試案例,該測試案例驗證資料組合中第一個元素的平方是否等於資料組合中的第二個元素,這樣是否更優雅呢? [譯註:若還不太熟悉 Java 8 的 Stream,可能反而比較難讀懂]

結論

JUnit 團隊成功提供一個新的、重新設計的 JUnit 版本,解決前個版本中幾乎所有的限制。注意到 JUnit 5 的 API 仍可能變動,開發團隊在公開的型別上加註 @API,並給予 ExperimentalMaintainedStable 等值識別。嘗試一下 JUnit 5,並保持總是綠燈!

This article is a considerably updated version of our initial coverage of JUnit 5 in the May/June issue. —Ed.

Learn more
JUnit 5 oicial documentation
Another overview of this release of JUnit 5

譯者的告白
翻譯的過程中,總覺得例子好像沒有很到位,例如測試階層的例子若沒有仔細想,會感受不到好處,延遲的例子也是,讓我想到最近在寫閒談軟體架構時也是如此,若要舉例,總是會想很久,什麼樣的例子不會太複雜又可以很到位呢?回到 JUnit 5,隨著語言的新特性,測試程式碼可讀性更高,但這也更考驗寫程式的開發者,怎樣才能用測試寫出像自然語言描述的需求與規格呢?

← JUnit 5:下個世代的單元測試 Part 2: 使用 JUnit 5 →