作为新一代的测试框架,Junit5中有很多大家喜欢的测试方案,个人认为最突出的就是能够进行参数化的测试(Parameterized Tests)

简介

通常,会遇到这样的情况,同一个测试案例,改变的只是测试时候输入的参数不同。按照之前的做法,可能会是通过每个输入参数都写一个测试,或者将测试参数封装到集合中循环遍历执行测试。在新版的Junit5中,已经提供了一种更加优雅的方式来进行。

该特性允许我们:该特性可以让我们运行单个测试多次,且使得每次运行仅仅是参数不同而已。

安装依赖

为了使用 JUnit 5 的参数化测试(parameterized tests)。需要在Junit Platform的基础上,导入而外的 junit-jupiter-params 架包。

maven:

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

Gradle:

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

简单的案例

比如,需要测试一个函数是判断输入值否为基数。

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

通过Parameterized Test,则可以写成如下的形式:

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

JUnit5 将会执行上面的测试6次,每次都会分配来之*@ValueSource*中不同的int参数.

如何定义不同参数的来源

上面简单的展示了如何通过不同的参数来运行同一个测试。但是很多时候并不仅仅是Int类型

简单值 Simple Value

@ValueSource 可以往测试方法中传递一个数据或者迭代器。可支持的简单参数如下:

  • short (with the shorts attribute)
  • byte (with the bytes attribute)
  • int (with the ints attribute)
  • long (with the longs attribute)
  • float (with the floats attribute)
  • double (with the doubles attribute)
  • char (with the chars attribute)
  • java.lang.String (with the strings attribute)
  • java.lang.Class (with the classes attribute)

值得注意的是,@ValueSource不允许传入Null值和Empty值。从JUnit 5.4开始,我们可以使用@NullSource、@EmptySource 和 @NullAndEmptySource 注解可以分别将单个null值、单个Empty和 Null和Empty 传递给参数化测试方法。

枚举类 Enum

为了运行将一个枚举类的所有的值传入到测试中,可以使用 @EnumSource注解。比如使用枚举类Month

@ParameterizedTest
@EnumSource(Month.class) // passing all 12 months
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));
}

通常的情况下,会被认为names是对匹配的这些名字的枚举类进行操作,但是通过mode=EXCLUDE属性可以设置为取反。

@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));
}

另外,也可以通过在names属性上增加正则表达式来操作这些迭代的字符串。

@ParameterizedTest
@EnumSource(value = Month.class, names = ".+BER", mode = EnumSource.Mode.MATCH_ANY)
void fourMonths_AreEndingWithBer(Month month) {
    EnumSet<Month> months =
      EnumSet.of(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER, Month.DECEMBER);
    assertTrue(months.contains(month));
}

CSV(最常用)

很多时候,需要同时传入参数预期结果,来验证测试逻辑。比如, 需要去测试toUpperCase()方法(能够将预期的String字符串转换成预期的大写字母字符串)。

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

其中 @CsvSource 注解接受一个以逗号分隔的数组,并且每个数组项都对应于CSV文件中的一行。该注解包含了一个 delimiter 属性,可以用来定义分割符(默认是逗号)。

CSV Files

同前面的CSV一样,只是把参数写到具体的CSV文件存储起来。通过@CsvFileSource注解说明文件路径。

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

通常情况下,@CsvFileSource注解回去解析每一行,但有些时候,第一行可能会是列名,所以在上面的方法中加上了numLinesToSkip 属性来跳过第一行。

方法 Method

通过@MethodSource注解可以传递一些复杂的迭代对象到测试中。

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

其中@MethodSource 注解需要匹配现有存在方法,通常会在同测试类中查询该方法,如果不在同测试类文件下,则需要加上方法名的全限定名。比如下面例子

class StringsUnitTest {
 
    @ParameterizedTest
    @MethodSource("com.baeldung.parameterized.StringParams#blankStrings")
    void isBlank_ShouldReturnTrueForNullOrBlankStringsExternalSource(String input) {
        assertTrue(Strings.isBlank(input));
    }
}
 
public class StringParams {
 
    static Stream<String> blankStrings() {
        return Stream.of(null, "", "  ");
    }
}

自定义参数提供器 Custom Argument Provider

通过实现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));
}

如何自定义注解

前面的都是Junit 参数化解析框架提供的注解,也可以自定义一些参数注解,以一种更加优美的方式来实现参数化得测试。比如,想实现一个从静态变量里面加载测试参数的注解。类似于如下的代码。

static Stream<Arguments> arguments = Stream.of(
  Arguments.of(null, true), // null strings should be considered blank
  Arguments.of("", true),
  Arguments.of("  ", true),
  Arguments.of("not blank", false)
);
 
@ParameterizedTest
@VariableSource("arguments")
void isBlank_ShouldReturnTrueForNullOrBlankStringsVariableSource(String input, boolean expected) {
    assertEquals(expected, Strings.isBlank(input));
}

JUnit5 提供了如下的两个基类来帮助我们实现。一个用来实现注解细节,一个用来提供测试参数

  • AnnotationConsumer 基类提供注解细节
  • ArgumentsProvider 基类提供测试的参数

实际的实现如下:

class VariableArgumentsProvider implements ArgumentsProvider, AnnotationConsumer<VariableSource> {
 
    private String variableName;
 
    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return context.getTestClass()
                .map(this::getField)
                .map(this::getValue)
                .orElseThrow(() -> new IllegalArgumentException("Failed to load test arguments"));
    }
 
    @Override
    public void accept(VariableSource variableSource) {
        variableName = variableSource.value();
    }
 
    private Field getField(Class<?> clazz) {
        try {
            return clazz.getDeclaredField(variableName);
        } catch (Exception e) {
            return null;
        }
    }
 
    @SuppressWarnings("unchecked")
    private Stream<Arguments> getValue(Field field) {
        Object value = null;
        try {
            value = field.get(null);
        } catch (Exception ignored) {}
 
        return value == null ? null : (Stream<Arguments>) value;
    }
}

参数的类型转换

隐式转换

假设通过@CsvSource 注解来重写了前面@EmumTests 测试。在@CSVSource中通过传入字符串,而不是枚举类

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

按理来说是应该失败的,但是实际运行你会发现,它能够正常的运行。

因为Junit5默认会对字符串进行隐式的转换。String默认可以转换成如下的几种类型

  • UUID
  • Locale
  • LocalDate, LocalTime, LocalDateTime, Year, Month, etc.
  • File and Path
  • URL and URI
  • Enum subclasses

显式转换

有些时候,需要提供一种自定义的显式参数类型转换器。例如,想将 yyyy/mm/dd 格式的字符串数据转换成LocalDate实例。

第一步是实现 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());
}

参数存取器

通常情况下,一个测试参数,会对应一个形参参数。但是当通过一个参数源传递多个参数的时候,则有些时候就会显得很大和混乱。这时候,可以通过一个参数存取器 ArgumentsAccessor来聚合这些参数,然后在使用的时候,根据索引来获得。比如,想测试下面的Person类中的 fullName方法

class Person {
 
    String firstName;
    String middleName;
    String lastName;
     
    // constructor
 
    public String fullName() {
        if (middleName == null || middleName.trim().isEmpty()) {
            return String.format("%s %s", firstName, lastName);
        }
 
        return String.format("%s %s %s", firstName, middleName, lastName);
    }
}

如果想测试fullName方法,则需要传入 firstName, middleName, lastName, 和 the expected fullName.。我们不通过定义不同的测试形参参数,而是通过 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());
}

讲道理,没有看出太大的优势)好处就是将所有的参数都聚合存储在一起,并且通过下面定义的几个方法来解析。

  • getString(index) 直接通过索引解析了具体的值,成字符串。(返回类型就是String)
  • get(index) 简单通过索引元素解析成 Object对象
  • get(index, type) 将指定的索引元素解析成指定的类型对象 type

参数聚合器

使用前面的参数存取器 ArgumentsAccessor可能会使得代码缺少可读性和复用性。可以提通过自定义一个aggregator来实现。

首先就是实现 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));
    }
}

然后通过指定 @AggregateWith 注解来引用

@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());
}

上面的 PersonAggregator用例中,聚合了最后的3个参数,并且实例化出了一个Person对象。

自定义显式名称 Customizing Display Names

默认情况下,测试运行之后显式的测试名如下

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

但是我们可以通过 @ParameterizedTest 注解中的 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));
}

显示的’ April is 30 days long’ 可能更加的表意。

在自定义显示名下,可以使用下面的几个占位符。

  • {index} 表示调用索引,从1开始,然后2,3等
  • {arguments} 表示完整的参数列表,以逗号分隔。
  • **{0}, {1}, …****.*表示单个参数名称。

总结

Junit5 会越来越流行,上面的相关源代码请参考 tutorials/testing-modules/junit5-annotations at master · eugenp/tutorials

参考文档


  1. https://www.baeldung.com/parameterized-tests-junit-5