JUnit 5 参数化测试
目录
如果您正在阅读这篇文章,说明您已经熟悉了 JUnit。让我为您概括一下 JUnit——在软件开发中,我们开发人员编写的代码可能是设计一个人的个人资料这样简单,也可能是在银行系统中进行付款这样复杂。在开发这些功能时,我们倾向于编写单元测试。顾名思义,单元测试的主要目的是确保代码的小、单独部分按预期功能工作。如果单元测试执行失败,这意味着该功能无法按预期工作。编写单元测试的一种工具是 JUnit。这些单元测试程序很小,但是非常强大,并且可以快速执行。如果您想了解更多关于 JUnit 5(也称为 JUnit Jupiter)的信息,请查看这篇JUnit5的文章。现在我们已经了解了 JUnit,接下来让我们聚焦于 JUnit 5 中的参数化测试。参数化测试可以解决在为任何新/旧功能开发测试框架时遇到的最常见问题。
开发团队通过利用方法和类来创建可重用且松散耦合的源代码。传递给代码的参数会影响其功能。例如,计算器类中的 sum 方法可以处理整数和浮点数值。JUnit 5 引入了执行参数化测试的能力,可以使用单个测试用例测试源代码,该测试用例可以接受不同的输入。这样可以更有效地进行测试,因为在旧版本的 JUnit 中,必须为每种输入类型创建单独的测试用例,从而导致大量的代码重复。
示例代码
本文附带有在 GitHub上 的一个可工作的示例代码。
设置
就像疯狂泰坦灭霸喜欢访问力量一样,您可以使用以下 Maven 依赖项来访问 JUnit5 中参数化测试的力量:
<dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-params</artifactId> <version>5.9.2</version> <scope>test</scope></dependency>
复制代码
让我们来写些代码,好吗?
我们的第一个参数化测试
现在,我想向您介绍一个新的注解 @ParameterizedTest。顾名思义,它告诉 JUnit 引擎使用不同的输入值运行此测试。
import static org.junit.jupiter.api.Assertions.assertEquals;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.ValueSource;
public class ValueSourceTest {
@ParameterizedTest @ValueSource(ints = { 2, 4 }) void checkEvenNumber(int number) { assertEquals(0, number % 2, "Supplied number is not an even number"); }}
复制代码
在上面的示例中,注解 @ValueSource 为 checkEvenNumber() 方法提供了多个输入。假设我们使用 JUnit4 编写相同的代码,即使它们的结果(断言)完全相同,我们也必须编写 2 个测试用例来覆盖输入 2 和 4。
当我们执行 ValueSourceTest 时,我们会看到什么:
ValueSourceTest
|_ checkEvenNumber
|_ [1] 2
|_ [2] 4
这意味着 checkEvenNumber() 方法将使用 2 个输入值执行。
在下一节中,让我们学习一下 JUnit5 框架提供的各种参数来源。
参数来源
JUnit5 提供了许多参数来源注释。下面的章节将简要概述其中一些注释并提供示例。
@ValueSource
@ValueSource 是一个简单的参数源,可以接受单个字面值数组。@ValueSource 支持的字面值类型有 short、byte、int、long、float、double、char、boolean、String 和 Class。
@ParameterizedTest@ValueSource(strings = { "a1", "b2" })void checkAlphanumeric(String word) { assertTrue(StringUtils.isAlphanumeric(word), "Supplied word is not alpha-numeric");}
复制代码
@NullSource & @EmptySource
假设我们需要验证用户是否已经提供了所有必填字段(例如在登录函数中需要提供用户名和密码)。我们使用注解来检查提供的字段是否为 null,空字符串或空格。
@ParameterizedTest@NullSourcevoid checkNull(String value) { assertEquals(null, value);}
@ParameterizedTest@EmptySourcevoid checkEmpty(String value) { assertEquals("", value);}
复制代码
@ParameterizedTest@NullAndEmptySourcevoid checkNullAndEmpty(String value) { assertTrue(value == null || value.isEmpty());}
复制代码
@ParameterizedTest@NullAndEmptySource@ValueSource(strings = { " ", " " })void checkNullEmptyAndBlank(String value) { assertTrue(value == null || value.isBlank());}
复制代码
@MethodSource
该注解允许我们从一个或多个测试类的工厂方法中加载输入,并生成一个参数流。
// Note: The test will try to load the supplied method@ParameterizedTest@MethodSource("checkExplicitMethodSourceArgs")void checkExplicitMethodSource(String word) {assertTrue(StringUtils.isAlphanumeric(word),"Supplied word is not alpha-numeric");}
static Stream<String> checkExplicitMethodSourceArgs() {return Stream.of("a1","b2");}
复制代码
// Note: The test will search for the source method// that matches the test-case method name@ParameterizedTest@MethodSourcevoid checkImplicitMethodSource(String word) { assertTrue(StringUtils.isAlphanumeric(word),"Supplied word is not alpha-numeric");}
static Stream<String> checkImplicitMethodSource() {return Stream.of("a1","b2");}
复制代码
// Note: The test will automatically map arguments based on the index@ParameterizedTest@MethodSourcevoid checkMultiArgumentsMethodSource(int number, String expected) { assertEquals(StringUtils.equals(expected, "even") ? 0 : 1, number % 2);}
static Stream<Arguments> checkMultiArgumentsMethodSource() { return Stream.of(Arguments.of(2, "even"), Arguments.of(3, "odd"));}
复制代码
// Note: The test will try to load the external method@ParameterizedTest@MethodSource("source.method.ExternalMethodSource#checkExternalMethodSourceArgs")void checkExternalMethodSource(String word) { assertTrue(StringUtils.isAlphanumeric(word),"Supplied word is not alpha-numeric");}// Note: The test will try to load the external method@ParameterizedTest@MethodSource("source.method.ExternalMethodSource#checkExternalMethodSourceArgs")void checkExternalMethodSource(String word) { assertTrue(StringUtils.isAlphanumeric(word),"Supplied word is not alpha-numeric");}
package source.method;import java.util.stream.Stream;
public class ExternalMethodSource { static Stream<String> checkExternalMethodSourceArgs() { return Stream.of("a1", "b2"); }}
复制代码
@CsvSource
该注解允许我们将参数列表作为逗号分隔的值(即 CSV 字符串字面量)传递,每个 CSV 记录都会导致执行一次参数化测试。它还支持使用 useHeadersInDisplayName 属性跳过 CSV 标头。
@ParameterizedTest@CsvSource({ "2, even","3, odd"})void checkCsvSource(int number, String expected) { assertEquals(StringUtils.equals(expected, "even") ? 0 : 1, number % 2);}
复制代码
@CsvFileSource
该注解允许我们使用类路径或本地文件系统中的逗号分隔值(CSV)文件。与 @CsvSource 类似,每个 CSV 记录都会导致执行一次参数化测试。它还支持各种其他属性 -numLinesToSkip、useHeadersInDisplayName、lineSeparator、delimiterString 等。
示例 1: 基本实现
@ParameterizedTest@CsvFileSource(files = "src/test/resources/csv-file-source.csv",numLinesToSkip = 1)void checkCsvFileSource(int number, String expected) { assertEquals(StringUtils.equals(expected, "even") ? 0 : 1, number % 2);}
复制代码
src/test/resources/csv-file-source.csv
NUMBER, ODD_EVEN
2, even
3, odd
示例 2:使用属性
@ParameterizedTest@CsvFileSource( files = "src/test/resources/csv-file-source_attributes.csv", delimiterString = "|", lineSeparator = "||", numLinesToSkip = 1)void checkCsvFileSourceAttributes(int number, String expected) { assertEquals(StringUtils.equals(expected, "even")? 0 : 1, number % 2);}
复制代码
src/test/resources/csv-file-source_attributes.csv
|| NUMBER | ODD_EVEN ||
|| 2 | even ||
|| 3 | odd ||
@EnumSource
该注解提供了一种方便的方法来使用枚举常量作为测试用例参数。支持的属性包括:
package java.time.temporal;
public enum ChronoUnit implements TemporalUnit { SECONDS("Seconds", Duration.ofSeconds(1)), MINUTES("Minutes", Duration.ofSeconds(60)),HOURS("Hours", Duration.ofSeconds(3600)), DAYS("Days", Duration.ofSeconds(86400)), //12 other units}
复制代码
ChronoUnit 是一个包含标准日期周期单位的枚举类型。
@ParameterizedTest@EnumSource(ChronoUnit.class)void checkEnumSourceValue(ChronoUnit unit) {assertNotNull(unit);}
复制代码
在此示例中,@EnumSource 将传递所有 16 个 ChronoUnit 枚举值作为参数。
@ParameterizedTest@EnumSource(names = { "DAYS", "HOURS" })void checkEnumSourceNames(ChronoUnit unit) { assertNotNull(unit);}
复制代码
@ArgumentsSource
该注解提供了一个自定义的可重用 ArgumentsProvider。ArgumentsProvider 的实现必须是外部类或静态嵌套类。
public class ArgumentsSourceTest {
@ParameterizedTest @ArgumentsSource(ExternalArgumentsProvider.class) void checkExternalArgumentsSource(int number, String expected) { assertEquals(StringUtils.equals(expected, "even") ? 0 : 1, number % 2, "Supplied number " + number + " is not an " + expected + " number"); }}
public class ExternalArgumentsProvider implements ArgumentsProvider {
@Override public Stream<? extends Arguments> provideArguments( ExtensionContext context) throws Exception {
return Stream.of(Arguments.of(2, "even"), Arguments.of(3, "odd")); }}
复制代码
public class ArgumentsSourceTest {
@ParameterizedTest @ArgumentsSource(NestedArgumentsProvider.class) void checkNestedArgumentsSource(int number, String expected) { assertEquals(StringUtils.equals(expected, "even")? 0 : 1, number % 2, "Supplied number " + number + " is not an " + expected + " number"); }
static class NestedArgumentsProvider implements ArgumentsProvider {
@Override public Stream<? extends Arguments> provideArguments( ExtensionContext context) throws Exception {
return Stream.of(Arguments.of(2, "even"), Arguments.of(3, "odd")); } }}
复制代码
参数转换
首先,想象一下如果没有参数转换,我们将不得不自己处理参数数据类型的问题。
源方法: Calculator 类
public int sum(int a, int b) { return a + b;}
复制代码
测试用例:
@ParameterizedTest@CsvSource({ "10, 5, 15" })void calculateSum(String num1, String num2, String expected) { int actual = calculator.sum(Integer.parseInt(num1), Integer.parseInt(num2)); assertEquals(Integer.parseInt(expected), actual);}
复制代码
如果我们有 String 参数,而我们正在测试的源方法接受 Integers,则在调用源方法之前,我们需要负责进行此转换。
JUnit5 提供了不同的参数转换方式
@ParameterizedTest@ValueSource(ints = { 2, 4 })void checkWideningArgumentConversion(long number) { assertEquals(0, number % 2);}
复制代码
使用 @ValueSource(ints = { 1, 2, 3 }) 进行参数化测试时,可以声明接受 int、long、float 或 double 类型的参数。
@ParameterizedTest@ValueSource(strings = "DAYS")void checkImplicitArgumentConversion(ChronoUnit argument) { assertNotNull(argument.name());}
复制代码
JUnit5 提供了几个内置的隐式类型转换器。转换取决于声明的方法参数类型。例如,用 @ValueSource(strings = "DAYS")注释的参数化测试会隐式转换为类型 ChronoUnit 的参数。
@ParameterizedTest@ValueSource(strings = { "Name1", "Name2" })void checkImplicitFallbackArgumentConversion(Person person) { assertNotNull(person.getName());}
public class Person { private String name; public Person(String name) { this.name = name; } //Getters & Setters}
复制代码
JUnit5 提供了一个回退机制,用于自动将字符串转换为给定目标类型,如果目标类型声明了一个适用的工厂方法或工厂构造函数。例如,用 @ValueSource(strings = { "Name1", "Name2" })注释的参数化测试可以声明接受一个类型为 Person 的参数,其中包含一个名为 name 且类型为 string 的单个字段。
@ParameterizedTest@ValueSource(ints = { 100 })void checkExplicitArgumentConversion( @ConvertWith(StringSimpleArgumentConverter.class) String argument) { assertEquals("100", argument);}
public class StringSimpleArgumentConverter extends SimpleArgumentConverter {
@Override protected Object convert(Object source, Class<?> targetType) throws ArgumentConversionException { return String.valueOf(source); }}
复制代码
如果由于某种原因,您不想使用隐式参数转换,则可以使用 @ConvertWith 注释来定义自己的参数转换器。例如,用 @ValueSource(ints = { 100 })注释的参数化测试可以声明接受一个类型为 String 的参数,使用 StringSimpleArgumentConverter.class 将整数转换为字符串类型。
参数聚合
@ArgumentsAccessor
默认情况下,提供给 @ParameterizedTest 方法的每个参数对应于一个方法参数。因此,当提供大量参数的参数源可以导致大型方法签名时,我们可以使用 ArgumentsAccessor 而不是声明多个参数。类型转换支持如上面的隐式转换所述。
@ParameterizedTest@CsvSource({ "John, 20", "Harry, 30" })void checkArgumentsAccessor(ArgumentsAccessor arguments) { Person person = new Person(arguments.getString(0), arguments.getInteger(1)); assertTrue(person.getAge() > 19, person.getName() + " is a teenager");}
复制代码
自定义聚合器
我们看到 ArgumentsAccessor 可以直接访问 @ParameterizedTest 方法的参数。如果我们想在多个测试中声明相同的 ArgumentsAccessor 怎么办?JUnit5 通过提供自定义可重用的聚合器来解决此问题。
@ParameterizedTest@CsvSource({ "John, 20", "Harry, 30" })void checkArgumentsAggregator( @AggregateWith(PersonArgumentsAggregator.class) Person person) { assertTrue(person.getAge() > 19, person.getName() + " is a teenager");}
public class PersonArgumentsAggregator implements ArgumentsAggregator {
@Override public Object aggregateArguments(ArgumentsAccessor arguments, ParameterContext context) throws ArgumentsAggregationException {
return new Person(arguments.getString(0),arguments.getInteger(1)); }}
复制代码
实现 ArgumentsAggregator 接口并通过 @AggregateWith 注释在 @ParameterizedTest 方法中注册它。当我们执行测试时,它会将聚合结果作为对应测试的参数提供。ArgumentsAggregator 的实现可以是外部类或静态嵌套类。
额外福利
由于您已经阅读完文章,我想给您一个额外的福利 - 如果您正在使用像Fluent assertions for java这样的断言框架,则可以将 java.util.function.Consumer 作为参数传递,其中包含断言本身。
@ParameterizedTest@MethodSource("checkNumberArgs")void checkNumber(int number, Consumer<Integer> consumer) { consumer.accept(number); }
static Stream<Arguments> checkNumberArgs() { Consumer<Integer> evenConsumer = i -> Assertions.assertThat(i % 2).isZero(); Consumer<Integer> oddConsumer = i -> Assertions.assertThat(i % 2).isEqualTo(1);
return Stream.of(Arguments.of(2, evenConsumer), Arguments.of(3, oddConsumer));}
复制代码
总结
JUnit5 的参数化测试功能通过消除重复测试用例的需要,提供多次使用不同输入运行相同测试的能力,实现了高效的测试。这不仅为开发团队节省了时间和精力,而且还增加了测试过程的覆盖范围和有效性。此外,该功能允许对源代码进行更全面的测试,因为可以使用更广泛的输入进行测试,从而增加了识别任何潜在的错误或问题的机会。总体而言,JUnit5 的参数化测试是提高代码质量和可靠性的有价值的工具。
【注】本文译自: JUnit 5 Parameterized Tests (reflectoring.io)
评论