1. 概述

JUnit 5 是 JUnit 的新一代版本,带来了许多现代化的特性,其中一项非常实用的功能就是 参数化测试(Parameterized Tests)。它允许我们使用不同的参数多次运行同一个测试方法。

本文将深入探讨 JUnit 5 中参数化测试的使用方式,帮助你写出更灵活、更简洁的测试代码。

2. 依赖配置

要使用 JUnit 5 的参数化测试功能,我们需要引入 junit-jupiter-params 模块。在 Maven 项目中,添加如下依赖:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.11.0-M2</version>
    <scope>test</scope>
</dependency>

如果使用 Gradle,则配置如下:

testCompile("org.junit.jupiter:junit-jupiter-params:5.10.0")

3. 初识参数化测试

假设我们有一个工具类方法,需要验证它的行为是否符合预期:

public class Numbers {
    public static boolean isOdd(int number) {
        return number % 2 != 0;
    }
}

要对这个方法进行参数化测试,只需添加 @ParameterizedTest 注解:

@ParameterizedTest
@ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE}) // 六个数字
void isOdd_ShouldReturnTrueForOddNumbers(int number) {
    assertTrue(Numbers.isOdd(number));
}

JUnit 5 测试运行器会执行这个测试方法六次,每次传入不同的参数值。

这个例子展示了参数化测试的两个关键点:

✅ 一个参数来源(这里是 int 数组)
✅ 一个接收参数的方式(这里是 number 参数)

不过这只是冰山一角,继续往下看。

4. 参数来源详解

参数化测试的核心在于 参数来源(Argument Sources),JUnit 5 提供了多种方式来定义这些来源。

4.1. 简单值:@ValueSource

通过 @ValueSource 注解,我们可以传入一组字面量值:

public class Strings {
    public static boolean isBlank(String input) {
        return input == null || input.trim().isEmpty();
    }
}

测试代码如下:

@ParameterizedTest
@ValueSource(strings = {"", "  "})
void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input) {
    assertTrue(Strings.isBlank(input));
}

⚠️ 注意事项:

  • @ValueSource 只支持基本类型和 StringClass 等少数类型
  • 每次只能传入一个参数
  • 不能传 null,即使是 String 类型也不行

4.2. 空值与空字符串:@NullSource、@EmptySource、@NullAndEmptySource

从 JUnit 5.4 开始,可以使用以下注解传递 null 或空值:

@ParameterizedTest
@NullSource
void isBlank_ShouldReturnTrueForNullInputs(String input) {
    assertTrue(Strings.isBlank(input));
}
@ParameterizedTest
@EmptySource
void isBlank_ShouldReturnTrueForEmptyStrings(String input) {
    assertTrue(Strings.isBlank(input));
}
@ParameterizedTest
@NullAndEmptySource
void isBlank_ShouldReturnTrueForNullAndEmptyStrings(String input) {
    assertTrue(Strings.isBlank(input));
}

还可以组合使用:

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"  ", "\t", "\n"})
void isBlank_ShouldReturnTrueForAllTypesOfBlankStrings(String input) {
    assertTrue(Strings.isBlank(input));
}

4.3. 枚举值:@EnumSource

要使用枚举值进行测试,可以使用 @EnumSource

@ParameterizedTest
@EnumSource(Month.class)
void getValueForAMonth_IsAlwaysBetweenOneAndTwelve(Month month) {
    int monthNumber = month.getValue();
    assertTrue(monthNumber >= 1 && monthNumber <= 12);
}

还可以通过 names 属性筛选特定枚举值:

@ParameterizedTest
@EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
void someMonths_Are30DaysLong(Month month) {
    final boolean isALeapYear = false;
    assertEquals(30, month.length(isALeapYear));
}

支持正向匹配、反向排除、正则匹配等模式:

@ParameterizedTest
@EnumSource(
  value = Month.class,
  names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER", "FEBRUARY"},
  mode = EnumSource.Mode.EXCLUDE)
void exceptFourMonths_OthersAre31DaysLong(Month month) {
    final boolean isALeapYear = false;
    assertEquals(31, month.length(isALeapYear));
}

4.4. CSV 字面量:@CsvSource

当需要传递多个参数时,可以使用 @CsvSource

@ParameterizedTest
@CsvSource({"test,TEST", "tEst,TEST", "Java,JAVA"})
void toUpperCase_ShouldGenerateTheExpectedUppercaseValue(String input, String expected) {
    String actualValue = input.toUpperCase();
    assertEquals(expected, actualValue);
}

支持自定义分隔符:

@ParameterizedTest
@CsvSource(value = {"test:test", "tEst:test", "Java:java"}, delimiter = ':')
void toLowerCase_ShouldGenerateTheExpectedLowercaseValue(String input, String expected) {
    String actualValue = input.toLowerCase();
    assertEquals(expected, actualValue);
}

4.5. CSV 文件:@CsvFileSource

也可以从 CSV 文件中读取参数:

input,expected
test,TEST
tEst,TEST
Java,JAVA
@ParameterizedTest
@CsvFileSource(resources = "/data.csv", numLinesToSkip = 1)
void toUpperCase_ShouldGenerateTheExpectedUppercaseValueCSVFile(String input, String expected) {
    String actualValue = input.toUpperCase();
    assertEquals(expected, actualValue);
}

支持跳过行、自定义分隔符、编码等配置。

4.6. 方法来源:@MethodSource

对于复杂对象,可以使用 @MethodSource 指定一个方法作为参数来源:

@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input, boolean expected) {
    assertEquals(expected, Strings.isBlank(input));
}

private static Stream<Arguments> provideStringsForIsBlank() {
    return Stream.of(
      Arguments.of(null, true),
      Arguments.of("", true),
      Arguments.of("  ", true),
      Arguments.of("not blank", false)
    );
}

如果方法名与测试方法名一致,可以省略参数:

@ParameterizedTest
@MethodSource
void isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument(String input) {
    assertTrue(Strings.isBlank(input));
}

private static Stream<String> isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument() {
    return Stream.of(null, "", "  ");
}

还可以引用外部类的方法:

@ParameterizedTest
@MethodSource("com.baeldung.parameterized.StringParams#blankStrings")
void isBlank_ShouldReturnTrueForNullOrBlankStringsExternalSource(String input) {
    assertTrue(Strings.isBlank(input));
}

4.7. 字段来源:@FieldSource(JUnit 5.11+)

从 JUnit 5.11 开始,支持使用静态字段作为参数来源:

static List<String> cities = Arrays.asList("Madrid", "Rome", "Paris", "London");

@ParameterizedTest
@FieldSource("cities")
void isBlank_ShouldReturnFalseWhenTheArgHasAtLEastOneCharacter(String arg) {
    assertFalse(Strings.isBlank(arg));
}

字段类型可以是 CollectionIterable、数组或 Stream

4.8. 自定义参数提供器:@ArgumentsSource

可以通过实现 ArgumentsProvider 接口自定义参数来源:

class BlankStringsArgumentsProvider implements ArgumentsProvider {

    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return Stream.of(
          Arguments.of((String) null), 
          Arguments.of(""), 
          Arguments.of("   ") 
        );
    }
}

然后使用:

@ParameterizedTest
@ArgumentsSource(BlankStringsArgumentsProvider.class)
void isBlank_ShouldReturnTrueForNullOrBlankStringsArgProvider(String input) {
    assertTrue(Strings.isBlank(input));
}

4.9. 自定义注解

可以将自定义提供器封装成注解,提升可读性:

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(VariableArgumentsProvider.class)
public @interface VariableSource {
    String value();
}

配合自定义的 VariableArgumentsProvider 实现,即可使用:

@ParameterizedTest
@VariableSource("arguments")
void isBlank_ShouldReturnTrueForNullOrBlankStringsVariableSource(String input, boolean expected) {
    assertEquals(expected, Strings.isBlank(input));
}

5. 可重复的参数源注解(JUnit 5.11+)

从 JUnit 5.11 开始,大多数参数源注解支持重复使用:

@ParameterizedTest
@MethodSource("asia")
@MethodSource("europe")
void whenStringIsLargerThanThreeCharacters_thenReturnTrue(String country) {
    assertTrue(country.length() > 3);
}

支持重复使用的注解包括:

  • @ValueSource
  • @EnumSource
  • @MethodSource
  • @FieldSource
  • @CsvSource
  • @CsvFileSource
  • @ArgumentsSource

6. 参数转换

6.1. 隐式转换

JUnit 5 提供了多种内置的隐式类型转换,例如:

@ParameterizedTest
@CsvSource({"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
void someMonths_Are30DaysLongCsv(Month month) {
    final boolean isALeapYear = false;
    assertEquals(30, month.length(isALeapYear));
}

支持的类型包括:

  • UUID
  • Locale
  • LocalDate, LocalTime, LocalDateTime, Year, Month
  • File, Path
  • URL, URI
  • 枚举子类

6.2. 显式转换

可以通过实现 ArgumentConverter 接口来自定义转换逻辑:

class SlashyDateConverter implements ArgumentConverter {

    @Override
    public Object convert(Object source, ParameterContext context)
      throws ArgumentConversionException {
        if (!(source instanceof String)) {
            throw new IllegalArgumentException("The argument should be a string: " + source);
        }
        try {
            String[] parts = ((String) source).split("/");
            int year = Integer.parseInt(parts[0]);
            int month = Integer.parseInt(parts[1]);
            int day = Integer.parseInt(parts[2]);

            return LocalDate.of(year, month, day);
        } catch (Exception e) {
            throw new IllegalArgumentException("Failed to convert", e);
        }
    }
}

然后使用 @ConvertWith 注解引用:

@ParameterizedTest
@CsvSource({"2018/12/25,2018", "2019/02/11,2019"})
void getYear_ShouldWorkAsExpected(
  @ConvertWith(SlashyDateConverter.class) LocalDate date, int expected) {
    assertEquals(expected, date.getYear());
}

7. 参数访问器:ArgumentsAccessor

当参数较多时,可以通过 ArgumentsAccessor 来简化方法签名:

@ParameterizedTest
@CsvSource({"Isaac,,Newton,Isaac Newton", "Charles,Robert,Darwin,Charles Robert Darwin"})
void fullName_ShouldGenerateTheExpectedFullName(ArgumentsAccessor argumentsAccessor) {
    String firstName = argumentsAccessor.getString(0);
    String middleName = (String) argumentsAccessor.get(1);
    String lastName = argumentsAccessor.get(2, String.class);
    String expectedFullName = argumentsAccessor.getString(3);

    Person person = new Person(firstName, middleName, lastName);
    assertEquals(expectedFullName, person.fullName());
}

8. 参数聚合器:ArgumentsAggregator

为了提高代码复用性,可以使用 ArgumentsAggregator 将多个参数聚合为一个对象:

class PersonAggregator implements ArgumentsAggregator {

    @Override
    public Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext context)
      throws ArgumentsAggregationException {
        return new Person(
          accessor.getString(1), accessor.getString(2), accessor.getString(3));
    }
}

使用方式如下:

@ParameterizedTest
@CsvSource({"Isaac Newton,Isaac,,Newton", "Charles Robert Darwin,Charles,Robert,Darwin"})
void fullName_ShouldGenerateTheExpectedFullName(
  String expectedFullName,
  @AggregateWith(PersonAggregator.class) Person person) {

    assertEquals(expectedFullName, person.fullName());
}

9. 自定义显示名称

默认情况下,参数化测试的显示名称会包含索引和参数内容:

├─ someMonths_Are30DaysLongCsv(Month)
│     │  ├─ [1] APRIL
│     │  ├─ [2] JUNE
│     │  ├─ [3] SEPTEMBER
│     │  └─ [4] NOVEMBER

可以通过 name 属性自定义:

@ParameterizedTest(name = "{index} {0} is 30 days long")
@EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
void someMonths_Are30DaysLong(Month month) {
    final boolean isALeapYear = false;
    assertEquals(30, month.length(isALeapYear));
}

显示效果:

├─ someMonths_Are30DaysLong(Month)
│     │  ├─ 1 APRIL is 30 days long
│     │  ├─ 2 JUNE is 30 days long
│     │  ├─ 3 SEPTEMBER is 30 days long
│     │  └─ 4 NOVEMBER is 30 days long

支持的占位符:

  • {index}:测试索引(从 1 开始)
  • {arguments}:完整参数列表
  • {0}, {1}, ...:单个参数

10. 总结

JUnit 5 的参数化测试功能非常强大,能够显著减少重复代码,提高测试覆盖率。通过灵活使用参数源、参数转换和自定义注解,可以写出更加简洁、易维护的测试代码。

完整示例代码可在 GitHub 上找到。


原始标题:Guide to JUnit 5 Parameterized Tests | Baeldung