当前位置:Java -> JUnit 5.7中的参数化测试:@EnumSource深入解析
参数化测试 允许开发人员以一系列输入值高效地测试他们的代码。在 JUnit 测试 领域,经验丰富的用户长时间以来一直在努力解决实现这些测试的复杂性问题。但是随着 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
传递多个参数在某种程度上得到了解决,它将每个字符串解析为一个记录,然后按字段逐个传递参数。这在处理长字符串和/或大量参数时很容易让人难以阅读。也受限于可以使用的类型 — 更多内容稍后再说。@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
允许您 "走私" 任意数量的数据字段。
因此,当您将来需要添加更多测试方法参数时,您只需在现有枚举中添加更多字段,而不用更改测试方法签名。当您将您的数据提供者用于多个测试时,这将变得非常宝贵。
对于其他来源,人们必须使用 ArgumentsAccessor
或 ArgumentsAggregator
来获得枚举自带的灵活性。
对于 Java 开发者来说,这应该是一个重要的因素。
从 CSV(文件或文本)中读取的参数,@MethodSource
或 @ArgumentsSource
,它们无法保证在编译时参数的数量和它们的类型将与签名匹配。
显然,JUnit 在运行时会抱怨,但请忘记来自您的 IDE 的任何代码协助。
就像以前一样,在将来扩展参数集时,采用类型安全的方法会是一个巨大的胜利。
这在很大程度上是相对于从 CSV 读取数据等基于文本的源的优势 — 在文本中编码的值需要转换为 Java 类型。
如果您有一个自定义类来从 CSV 记录中实例化,您可以使用 ArgumentsAggregator
来实现。但是,您的数据声明仍然不是类型安全的 — 在方法签名和声明的数据之间存在任何不匹配都会在 "聚合" 参数时在运行时跳出来。更不用说声明聚合器类会需要更多的支持代码让您的参数化工作。因此,我们更偏好选择 @CsvSource
而不是 @EnumSource
以避免额外的代码。
与其他方法不同,枚举源对于参数集(枚举实例)和它们包含的所有参数(枚举字段)都有 Java 符号。它们提供了一个直观的地方来以更自然的形式 - JavaDoc 来附加文档。
这并不是说文档不能放在其他地方,但它将会 — 从定义上 — 放在离文档对象更远的地方,因此更难找到,更容易变得过时。
现在:枚举。就是。类。
许多初级开发人员认为 Java 枚举的强大之处尚未被充分认识。
在其他编程语言中,它们仅仅是被美化的常量。但在 Java 中,它们是方便的小型 Flyweight 设计模式 的实现,具有类的大部分优势。
这为什么是一件好事呢?
和其他类一样,枚举可以添加方法。
如果测试参数被在多个测试之间重复使用 —— 相同的数据,只是稍微不同的测试方式。为了有效地处理这些参数,避免发生大量的复制粘贴,一些辅助代码需要在这些测试之间共享。
这不是一个辅助类和一些静态方法无法“解决”的问题。
尽管这是过程式编程的唯一方式,但在面向对象的世界中,我们可以做得更好。
直接在枚举声明中声明“辅助”方法,我们可以将代码移到数据所在的地方。或者用面向对象的术语来说,辅助方法将成为枚举实现的测试装置的“行为”。这不仅使代码更符合惯用法(在实例上调用明智的方法,而不是通过静态方法传递数据),而且也更容易在测试用例之间重用枚举参数。
枚举可以实现带有(默认)方法的接口。当合理使用时,这可以用于在多个数据提供者 —— 多个枚举之间共享行为。
一个容易想到的例子是针对正向和负向测试分别定义两个枚举。如果它们代表相似类型的测试装置,那么它们很可能有一些共享的行为。
让我们以一个假设的源代码文件转换工具的测试套件来说明这一点,它与执行 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 —— 到底代表什么。
请注意,枚举如何允许使用复杂类型(Set
,RuntimeException
),没有任何限制或神奇的转换。传递这样的数据也完全是类型安全的。
现在,我知道你在想些什么。这太啰嗦了。好吧,有点啰嗦。实际上,你将会有更多的数据样本需要验证,因此与之相比,模板代码的数量将显得不那么重要。
此外,请看看如何利用相同的枚举和它们的辅助方法编写相关的测试:
@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深入解析