参数化测试允许开发人员使用一系列输入值高效地测试他们的代码。在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
注解必须与几个提供源注解之一相伴,描述从哪里获取参数。参数的来源通常被称为“数据提供者”。
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
。看看这个有四个参数集,每个参数集有两个值的非平凡例子。
- 注意 — 虽然
@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中,它们是方便的小型实现,采用了享元设计模式,并具有(大部分)完整类的优点。
这为什么是件好事?
测试固件相关行为
与任何其他类一样,枚举可以添加方法。
如果枚举测试参数在多个测试之间重用——相同的数据,只是测试的方式略有不同,这就变得很有用。为了在不进行大量复制和粘贴的情况下有效地使用这些参数,需要在那些测试之间共享一些辅助代码。
这不是一个辅助类和几个静态方法不能“解决”的问题。
- 旁注:注意这种设计存在功能 envy。测试方法——或者更糟糕,辅助类方法——将不得不从枚举对象中提取数据,以对该数据执行操作。
虽然这是(在)过程式编程中的(唯一)方式,但在面向对象的世界里,我们可以做得更好。
直接在枚举声明中声明“辅助”方法,我们将把代码移动到数据所在的位置。或者,用OOP的术语来说,辅助方法将变成作为枚举实现的测试固件的“行为”。这将不仅使代码更加符合习惯(在实例上调用合理的方法而不是静态方法传递数据),而且也使得跨测试用例重用枚举参数变得更加容易。
继承
枚举可以实现对具有(默认)方法接口的实现。当合理使用时,这可以用来在几个数据提供者之间共享行为——几个枚举。
一个容易想到的例子是分别为正测试和负测试创建独立的枚举。如果它们表示类似的测试固件,那么它们很可能有一些行为需要共享。
空谈无益
让我们用一个假设的源代码文件转换器的测试套件来演示这一点,这个转换器与执行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"));
// 许多,许多其他的...
@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、空字符串或集合),让人想知道第七个参数——你知道,其中的一个null——实际上代表了什么。
注意枚举是如何启用使用复杂类型(Set
、RuntimeException
)而不受任何限制或魔法转换的。传递这样的数据也是完全类型安全的。
现在,我知道你在想什么。这非常冗长。好吧,在一定程度上是的。实际上,你将会有更多的数据样本需要验证,所以模板代码的数量在比较上会显得不那么重要。
另外,看看相关的测试是如何通过使用相同的枚举及其辅助方法来编写的:
@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符号(枚举元素)的另一个好处是,它们可以单独使用;完全独立于数据提供者/参数化测试。因为它们有合理的名称且自包含(在数据和行为的层面上),它们有助于使测试变得清晰易读。
@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