使用JUnit 5.7進行測試參數化:深入探討@EnumSource

參數化測試讓開發者能夠有效地使用多種輸入值來測試他們的代碼。在JUnit 測試的領域中,有經驗的使用者長期以來一直在與實現這些測試的複雜性搏鬥。但隨著JUnit 5.7的發布,測試參數化的新時代來臨,為開發者提供了一流的支援和增強的能力。讓我們深入了解一下JUnit 5.7為參數化測試帶來的令人振奮的可能性!

JUnit 5.7 文档中的參數化範例

讓我們看看一些文檔中的例子:

Java

 

@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
    assertTrue(StringUtils.isPalindrome(candidate));
}

@ParameterizedTest
@CsvSource({
    "apple,         1",
    "banana,        2",
    "'lemon, lime', 0xF1",
    "strawberry,    700_000"
})
void testWithCsvSource(String fruit, int rank) {
    assertNotNull(fruit);
    assertNotEquals(0, rank);
}

@ParameterizedTest
@MethodSource("stringIntAndListProvider")
void testWithMultiArgMethodSource(String str, int num, List<String> list) {
    assertEquals(5, str.length());
    assertTrue(num >=1 && num <=2);
    assertEquals(2, list.size());
}

static Stream<Arguments> stringIntAndListProvider() {
    return Stream.of(
        arguments("apple", 1, Arrays.asList("a", "b")),
        arguments("lemon", 2, Arrays.asList("x", "y"))
    );
}

《@ParameterizedTest》注解必須與幾個提供的源注解之一一起使用,以描述從哪裡獲取參數。參數的來源通常被稱為“數據提供者”。

I will not dive into their detailed description here: the JUnit user guide does it better than I could, but allow me to share several observations:

  • 《@ValueSource》僅限於提供單個參數值。換句話說,測試方法不能有超過一個參數,而且可以使用 的類型也受到限制
  • 傳遞多個參數在一定程度上由《@CsvSource》解決,它將每個字符串解析為一條記錄,然後按字段逐個傳遞為參數。當字符串長或參數多時,這可能會變得難以閱讀。可以使用的類型也受到限制——稍後會有更多關於這一點的介紹。
  • 所有在注解中声明实际值的源都限制为编译时常量(Java注解的限制,而非JUnit)。
  • @MethodSource@ArgumentsSource 提供了一個流/集合,其中包含(無類型的)n-元組,然後將其作為方法參數傳遞。支持各種實際類型來表示n-元組的序列,但無法保證它們會適合方法的參數列表。這種類型的源需要額外的方法或類,但它對於從何處以及如何獲取測試數據沒有限制。

正如你所看到的,可用的源類型從簡單的(使用簡單,但功能有限)到極端靈活的(需要更多的代碼才能運作)。

  • 旁註 — 這通常意味著良好的設計:基本功能需要少量的代碼,當用於實現更複雜的使用案例時,增加額外的複雜性是合理的。

看起來與這個假設的從簡單到靈活的連續體不相符的是 @EnumSource。請看看這個具有4個參數集(每個2個值)的非平凡範例。

  • 註釋 — 雖然 @EnumSource 將枚舉的值作為單個測試方法的參數傳遞,從概念上講,測試是由枚舉的字段參數化的,這對參數的數量沒有限制。
Java

 

    enum Direction {
        UP(0, '^'),
        RIGHT(90, '>'),
        DOWN(180, 'v'),
        LEFT(270, '<');

        private final int degrees;
        private final char ch;

        Direction(int degrees, char ch) {
            this.degrees = degrees;
            this.ch = ch;
        }
    }

    @ParameterizedTest
    @EnumSource
    void direction(Direction dir) {
        assertEquals(0, dir.degrees % 90);
        assertFalse(Character.isWhitespace(dir.ch));
        
        int orientation = player.getOrientation();
        player.turn(dir);
        assertEquals((orientation + dir.degrees) % 360, player.getOrientation());
    }

想想看:硬编码的值列表严重限制了其灵活性(无法使用外部或生成数据),而声明enum所需的大量额外代码使得这种选择相对于@CsvSource来说显得相当冗长。

但这只是第一印象。我们将看到当利用Java枚举的真正力量时,这可以变得多么优雅。

  • 旁注:本文没有讨论生产代码中枚举的验证。当然,无论您选择如何验证它们,这些枚举都必须声明。相反,它关注的是何时以及如何将测试数据表达为枚举的形式。

何时使用它

有些情况下,枚举的表现比其他选择更好:

每个测试多个参数

当您只需要一个参数时,您很可能不想让事情比@ValueSource更复杂。但是,一旦您需要多个参数——比如说,输入和预期结果——您就必须求助于@CsvSource, @MethodSource/@ArgumentsSource @EnumSource

在某种程度上,enum允许您“悄悄带入”任何数量的数据字段。

所以,当您在未来需要添加更多测试方法参数时,您只需在现有的枚举中添加更多字段,而无需触摸测试方法的签名。当您在多个测试中重用数据提供者时,这变得非常有价值。

對於其他來源,必須使用ArgumentsAccessorArgumentsAggregator來獲得枚舉出廠時具有的靈活性。

類型安全

對於Java開發人員來說,這應該是個非常重要的點。

從CSV(文件或字面量)讀取的參數,@MethodSource@ArgumentsSource,它們不能提供編譯時的保證,參數的數量及其類型將與簽名匹配。

顯然,JUnit會在運行時報錯,但忘記來自IDE的任何代碼輔助。

與之前一樣,當您為多個測試重用相同的參數時,這會累加。使用類型安全的 approach 區會在將來擴展參數集時帶來巨大的好處。

自定義類型

這主要是優於基于文本的來源,如從CSV讀取數據的那些——文本中編碼的值需要轉換為Java類型。

如果您有一個從CSV記錄實例化的自定義類,您可以使用ArgumentsAggregator來完成。然而,您的數據聲明仍然不是類型安全的——當在”匯總”參數時,方法簽名與聲明的數據之間的任何不匹配都會在運行時彈出。更不用說聲明聚合器類增加了更多支持代碼,這是您的參數化工作所需的。而我們曾經偏好使用@CsvSource而不是@EnumSource以避免額外的代碼。

可文檔化

與其他方法不同,枚舉源具有Java符號,既用於參數集(枚舉實例),也用於它們包含的所有參數(枚舉字段)。它們提供了直接附加說明的地方,這種形式更加自然 — JavaDoc。

並不是說說明不能放在其他地方,而是按照定義,它將放在它所說明的內容較遠的地方,因此更難找到,且更容易過時。

但還有更多!

現在:枚舉。就是。類。

感覺許多初級開發人員還沒有真正意識到Java枚舉的強大之處。

在其他編程語言中,它們真的只是 glorified constants(優化過的常量)。但在Java中,它們是方便的小型實現,采用了飛重量設計模式(具有完整類的許多優勢)。

這為什麼是好事?

測試固件相關行為

與任何其他類一樣,枚舉可以向其添加方法。

如果枚舉測試參數在多個測試之間重用 — 數據相同,只是稍微有不同的測試方式,這會變得很方便。為了在不進行大量複製和粘貼的情況下有效地使用參數,這些測試之間需要共享一些幫助代碼。

這不是一個幫助類和幾個靜態方法不能“解決”的問題。

  • 旁註:請注意這種設計受到功能妒忌的影響。測試方法 — 或者更糟,輔助類別方法 — 必須從枚舉對象中提取數據以對該數據進行操作。

儘管這是(唯一的)過程式編程方式,但在面向對象的世界裡,我們可以做得更好。

直接在枚舉聲明中聲明「輔助」方法,我們會將代碼移動到數據所在的地方。或者,用面向對象的行話來說,輔助方法會成為實現為枚舉的測試固件「行為」。這不僅會使代碼更加符合語法(在實例上調用合理的 方法而不是傳遞數據的靜態方法),還會使跨測試用例重用枚舉參數變得更容易。

繼承

枚舉可以實現帶有(默認)方法的接口。當合理使用時,這可以被用來在多個數據提供者 — 多個枚舉 — 之間共享行為。

一個很容易想到的例子是分別為正數和負數測試創建的獨立枚舉。如果它們代表類似的測試固件,那麼它們很可能有一些共享的行為。

空談無價

讓我們在假設的源代碼文件轉換器的測試套件中說明這一點,這個轉換器與執行 Python 2 到 3 轉換的那個非常相似。

為了真正對這樣一個全面的工具有所信心,最終會有一套大量的輸入文件展現語言的各種方面,並且有匹配的文件來比對轉換結果。除了這些,還需要驗證對於有問題的輸入,用戶會收到哪些警告/錯誤。

這對於參數化測試來說是自然適合的,因為有大量的樣本需要驗證,但它並不完全適合JUnit的任何簡單參數源,因為這些數據相對複雜。

請見下文:

Java

 

    enum Conversion {
        CLEAN("imports-correct.2.py", "imports-correct.3.py", Set.of()),
        WARNINGS("problematic.2.py", "problematic.3.py", Set.of(
                "Using module 'xyz' that is deprecated"
        )),
        SYNTAX_ERROR("syntax-error.py", new RuntimeException("Syntax error on line 17"));
        // 很多,很多其他的...

        @Nonnull
        final String inFile;
        @CheckForNull
        final String expectedOutput;
        @CheckForNull
        final Exception expectedException;
        @Nonnull
        final Set expectedWarnings;

        Conversion(@Nonnull String inFile, @Nonnull String expectedOutput, @NotNull Set expectedWarnings) {
            this(inFile, expectedOutput, null, expectedWarnings);
        }

        Conversion(@Nonnull String inFile, @Nonnull Exception expectedException) {
            this(inFile, null, expectedException, Set.of());
        }

        Conversion(@Nonnull String inFile, String expectedOutput, Exception expectedException, @Nonnull Set expectedWarnings) {
            this.inFile = inFile;
            this.expectedOutput = expectedOutput;
            this.expectedException = expectedException;
            this.expectedWarnings = expectedWarnings;
        }

        public File getV2File() { ... }

        public File getV3File() { ... }
    }

    @ParameterizedTest
    @EnumSource
    void upgrade(Conversion con) {

        try {
            File actual = convert(con.getV2File());
            if (con.expectedException != null) {
                fail("No exception thrown when one was expected", con.expectedException);
            }
            assertEquals(con.expectedWarnings, getLoggedWarnings());
            new FileAssert(actual).isEqualTo(con.getV3File());
        } catch (Exception ex) {
            assertTypeAndMessageEquals(con.expectedException, ex);
        }
    }

使用枚舉不會限制我們數據可以有多複雜。正如你所看到的,我們可以在枚舉中定義多個方便的構造器,所以聲明新的參數集非常乾淨整齊。這避免了使用過長的參數列表,這樣的列表常常充滿了很多”空的”值(null、空字符串或集合),讓人疑惑第7個參數——你知道,其中一個null——到底代表了什麼。

注意枚舉如何讓複合類型(SetRuntimeException)的使用沒有任何限制或神奇的轉換。傳遞這樣的數據也是完全類型安全的。

現在,我知道你在想什麼。這太囉嗦了。好吧,在一定程度上。現實情況是,你將會有更多數據樣本需要驗證,所以相比之下,模板代碼的量將會不太顯著。

還可以看到,如何利用同一個枚舉以及它們的幫助方法來編寫相關的測試:

Java

 

    @ParameterizedTest
    @EnumSource
    // 升級已經升級的文件總是會通過,不會進行任何更改,也不會發出警告。
    void upgradeFromV3toV3AlwaysPasses(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV3File());
    }

    @ParameterizedTest
    @EnumSource
    // 復原升級過程中創建的文件應該始終能夠無警告通過。
    void downgrade(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV2File());
    }

還是有更多的話要說

從概念上講,@EnumSource鼓勵您創建一個複雜的、機器可讀的單個測試情景描述,模糊了數據提供者和測試固件之間的界限。

將每個數據集表示為Java符號(枚舉元素)的另一点優勢在於,它們可以單獨使用;完全脫離數據提供器/參數化測試。由於它們具有合理的名稱并且是自包含的(在數據和行為方面),它們有助於創造出整洁且易於閱讀的測試。

Java

 

@Test
void warnWhenNoEventsReported() throws Exception {
    FixtureXmls.Invalid events = FixtureXmls.Invalid.NO_EVENTS_REPORTED;
    
    // read() 是由所有 FixtureXmls 共享的輔助方法
    try (InputStream is = events.read()) {
        EventList el = consume(is);
        assertEquals(Set.of(...), el.getWarnings());
    }
}

現在,@EnumSource 不會成為您最常使用的參數源之一,這是一件好事,因為過度使用它並沒有好處。但在適當的情況下,知道如何充分利用它提供的功能會很有幫助。

Source:
https://dzone.com/articles/junit5-parameterized-tests-with-enumsource