当前位置:Java -> JUnit 5.7中的参数化测试:@EnumSource深入解析

JUnit 5.7中的参数化测试:@EnumSource深入解析

参数化测试 允许开发人员以一系列输入值高效地测试他们的代码。在 JUnit 测试 领域,经验丰富的用户长时间以来一直在努力解决实现这些测试的复杂性问题。但是随着 JUnit 5.7 的发布,测试参数化的新时代来临,为开发人员提供了一流的支持和增强的功能。让我们深入探讨一下 JUnit 5.7 为参数化测试带来的令人兴奋的可能性!

来自 JUnit 5.7 文档的参数化示例

让我们从文档中看一些示例:

@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 注解必须与提供的源注解之一相伴,描述参数应该从哪里获取。参数的来源通常被称为 "数据提供者"。

我将不在这里深入讨论它们的详细描述: JUnit 用户指南比我做得更好,但请允许我分享一些观察:

  •  @ValueSource 仅限于提供单个参数值。换句话说,测试方法不能有多个参数,并且 可以使用的类型也受限
  • 通过 @CsvSource 传递多个参数在某种程度上得到了解决,它将每个字符串解析为一个记录,然后按字段逐个传递参数。这在处理长字符串和/或大量参数时很容易让人难以阅读。也受限于可以使用的类型 — 更多内容稍后再说。
  • 所有在注解中声明实际值的来源都受限于编译时常量的值(这是 Java 注解 的限制,而不是 JUnit 的)。
  • @MethodSource@ArgumentsSource 提供一串/集合 (无类型) 的 n-元组,然后将其作为方法参数传递。支持各种实际类型来表示 n-元组的序列,但它们都不能保证它们将符合方法的参数列表。这种类型的来源需要额外的方法或类,但它提供了从何处以及如何获取测试数据的限制。

正如你所看到的,可用的来源类型从简单的(使用简单,但功能有限)到需要更多代码才能工作的终极灵活类型都有。

  • 旁注 — 这通常是一个良好设计的标志:需要少量代码进行基本功能,当用于实现更高要求的用例时,额外的复杂性是合理的。

似乎并不符合这种假设的简单到灵活连续性的是 @EnumSource。看看这个有 2 个值的四组参数的非平凡示例。

  • 注意 — 虽然 @EnumSource 将枚举的值作为单个测试方法参数传递,但从概念上讲,测试是由枚举字段的字段来进行参数化的,它对参数数量没有任何限制。
    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 的任何代码协助。

就像以前一样,在将来扩展参数集时,采用类型安全的方法会是一个巨大的胜利。

自定义类型

这在很大程度上是相对于从 CSV 读取数据等基于文本的源的优势 — 在文本中编码的值需要转换为 Java 类型。

如果您有一个自定义类来从 CSV 记录中实例化,您可以使用 ArgumentsAggregator 来实现。但是,您的数据声明仍然不是类型安全的 — 在方法签名和声明的数据之间存在任何不匹配都会在 "聚合" 参数时在运行时跳出来。更不用说声明聚合器类会需要更多的支持代码让您的参数化工作。因此,我们更偏好选择 @CsvSource 而不是 @EnumSource 以避免额外的代码。

可文档化

与其他方法不同,枚举源对于参数集(枚举实例)和它们包含的所有参数(枚举字段)都有 Java 符号。它们提供了一个直观的地方来以更自然的形式 - JavaDoc 来附加文档。

这并不是说文档不能放在其他地方,但它将会 — 从定义上 — 放在离文档对象更远的地方,因此更难找到,更容易变得过时。

但它更多!

现在:枚举。就是。类。

许多初级开发人员认为 Java 枚举的强大之处尚未被充分认识。

在其他编程语言中,它们仅仅是被美化的常量。但在 Java 中,它们是方便的小型 Flyweight 设计模式 的实现,具有类的大部分优势。

这为什么是一件好事呢?

测试装置相关行为

和其他类一样,枚举可以添加方法。

如果测试参数被在多个测试之间重复使用 —— 相同的数据,只是稍微不同的测试方式。为了有效地处理这些参数,避免发生大量的复制粘贴,一些辅助代码需要在这些测试之间共享。

这不是一个辅助类和一些静态方法无法“解决”的问题。

  • 旁注:请注意,这样的设计会遭受 Feature Envy 的诟病。测试方法 —— 或者更糟糕的是,辅助类方法 —— 需要从枚举对象中提取数据,才能对数据执行操作。

尽管这是过程式编程的唯一方式,但在面向对象的世界中,我们可以做得更好。

直接在枚举声明中声明“辅助”方法,我们可以将代码移到数据所在的地方。或者用面向对象的术语来说,辅助方法将成为枚举实现的测试装置的“行为”。这不仅使代码更符合惯用法(在实例上调用明智的方法,而不是通过静态方法传递数据),而且也更容易在测试用例之间重用枚举参数。

继承

枚举可以实现带有(默认)方法的接口。当合理使用时,这可以用于在多个数据提供者 —— 多个枚举之间共享行为。

一个容易想到的例子是针对正向和负向测试分别定义两个枚举。如果它们代表相似类型的测试装置,那么它们很可能有一些共享的行为。

言谈便宜

让我们以一个假设的源代码文件转换工具的测试套件来说明这一点,它与执行 Python 2 到 3 转换的工具相当相似。

要对这样一种全面工具的实际行为有真正的信心,人们最终会得到一套庞大的输入文件,展现语言的各个方面,并且有相匹配的文件来对比转换的结果。此外,还需验证对于有问题的输入会给用户提供怎样的警告/错误信息。

由于有大量的样本需要验证,这很适合使用参数化测试,但它不太适合任何简单的 JUnit 参数源,因为数据有点复杂。

见下文:

    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"));
        // Many, many others ...

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

        Conversion(@Nonnull String inFile, @Nonnull String expectedOutput, @NotNull Set<String> 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<String> 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),没有任何限制或神奇的转换。传递这样的数据也完全是类型安全的。

现在,我知道你在想些什么。这太啰嗦了。好吧,有点啰嗦。实际上,你将会有更多的数据样本需要验证,因此与之相比,模板代码的数量将显得不那么重要。

此外,请看看如何利用相同的枚举和它们的辅助方法编写相关的测试:

    @ParameterizedTest
    @EnumSource
    // Upgrading files already upgraded always passes, makes no changes, issues no warnings.
    void upgradeFromV3toV3AlwaysPasses(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV3File());
    }

    @ParameterizedTest
    @EnumSource
    // Downgrading files created by upgrade procedure is expected to always pass without warnings.
    void downgrade(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV2File());
    }


话后续

从概念上讲,@EnumSource 鼓励您创建一个复杂的、可机器阅读的个别测试场景描述,模糊了数据提供者和测试装置之间的界线。

将每个数据集表达为一个 Java 符号(枚举元素)的一个伟大之处是它们可以被单独使用;完全摆脱数据提供者 / 参数化测试。由于它们有合理的名称,并且它们是自包含的(在数据和行为方面),它们为可读性高的测试做出了贡献。

@Test
void warnWhenNoEventsReported() throws Exception {
    FixtureXmls.Invalid events = FixtureXmls.Invalid.NO_EVENTS_REPORTED;
    
    // read() is a helper method that is shared by all FixtureXmls
    try (InputStream is = events.read()) {
        EventList el = consume(is);
        assertEquals(Set.of(...), el.getWarnings());
    }
}


现在,@EnumSource 不会成为您最常用的参数源之一,这是一件好事,因为过度使用它没有好处。但在合适的情况下,了解如何充分利用它的所有内容是很有帮助的。

推荐阅读: 40.如何通过Redis实现异步队列?

本文链接: JUnit 5.7中的参数化测试:@EnumSource深入解析