单元测试之JUnit 5 参数化测试使用手册

1. 概要

junit5是下一代JUnit测试框架,新增了很多特性帮助开发人员更好得编写测试用例。其中一大特性就是参数化测试,其目的就是让我们可以使用不同的参数多次执行一个测试方法,从而覆盖不同的条件分支。(简单来说就是既Cover 所有的情况,还能减少 Duplicate Code )

在这边教程中,我们将深度探索参数化教程。现在开始吧!

2. 依赖

为了使用JUnit 5的参数化测试,我们需要从JUnit平台引入 junit-jupiter-params包。
如果我们项目使用Maven来管理,那么就需要在pom.xml中加入如下依赖

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

如果我们使用Gradle来编译项目,则需要在gradle的配置加下如下代码:

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

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}) // six numbers
void isOdd_ShouldReturnTrueForOddNumbers(int number) {
    assertTrue(Numbers.isOdd(number));
}

Junit 5的测试执行器会执行上述测试用例,然后isOdd方法会被执行6次,每次从@ValueSource的整型数组里拿出一个参数作为isOdd方法的入参。

从这个示例我们可以看出,执行参数化测试需要2个条件:
1. 参数源 , 这里是一个整型数组
2. 接收参数的地方, 在这里就是测试方法上的number参数

但是从这个例子中,还有一个执行参数化测试的必要条件,目前暂时看不出来,我们继续往下看

4. 参数源

现在我们知道了,一个参数化测试会使用不同的参数重复执行多次。那么这里的参数我们不仅仅可以使用上述的数字,让我们来体验一下!

4.1. 简单值(Simple Value)

通过使用 @ValueSource 注解, 我们可以使用数组来对不同的参数逐一执行测试用例

举例来说,假设我们要测试如下isBlank这一简单方法

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

我们期望这个方法能够对null或者空字符串返回true。所以我们可以写一个如下的参数化测试去对原函数的行为进行断言:

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

可以看出,上述测试用例会被执行2次,每次都将数组里的一个参数作为方法的入参来执行。

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

当前,使用参数化测试也可以每次执行时只是有一个参数(也就是说参数源的数组只有一个元素)

在进一步学习之前,是否有人注意到目前为止我们都没有以null为参数来进行验证? 因为这里有一个限制:哪怕我们
使用String或者Class数组作为参数源,我们也不能在@ValueSource注解里使用null作为入参

4.2. Null and Empty Values

在JUnit 5.4,我们可以单独用 @NullSource来验证参数为null的参数化测试的执行

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

因为基本类型参数不能被赋值为null, 所以我们不能使用@NullSource作为基本参数

类似的,还有一个 @EmptySource注解,可以帮助我们验证参数为empty的情形

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

上述测试代码里使用了@EmptySource注解,就会以empty 空参数作为入参执行当前测试用例

对于String类型的参数,就会以一个空字符串为参数传递测试。此外,@EmptySource注解还可以对集合和数组参数提供空值

为了同时验证参数为null和空的情形,我们可以使用@NullAndEmptySource这个组合注解,同时包含了@EmptySource
和@NullSource的功能

@ParameterizedTest
@NullAndEmptySource
void isBlank_ShouldReturnTrueForNullAndEmptyStrings(String input) {
    assertTrue(Strings.isBlank(input));
}
    //这是我自己加的使用@EmptySource作为List的参数条件
    @ParameterizedTest
    @EmptySource
    void isBlank_ShouldReturnTrueForNullAndEmptyStrings(List input) {
        assertTrue(input != null && input.size() == 0);
    }

和@EmptySource一样,上述组合注解@NullAndEmptySource也可以对String,集合,数组类型参数进行参数化测试

为了让参数化测试可以一次执行更多的参数条件,我们可以将 @ValueSource, @NullSource, @EmptySource 三个注解一起使用,如下:

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

为了以给定Enum枚举类型作为参数执行测试,我们可以使用@EnumSource注解

比如,下面我们就以1-12月所有的月份为参进行了验证断言

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

此外,我们还可以使用注解的names属性,只执行我们想要执行的参数枚举条件
下面就是一个验证非闰年4月,9月,6月,11月都是30天的断言

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

In addition to literal strings, we can pass a regular expression to the names attribute:
另外对于字符串的枚举类型,我们还可以使用正则表达式作为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));
}

@EnumSource和@ValueSource一样,每次执行测试用例仅能使用枚举类的其中一个值作为参数

4.4. CSV Literals

现在有一个场景,假设我们想要验证 toUpperCase() 方法是否可以正确地把一个字符串转化为对应的大写。此时@ValueSource就不太够用了

为了满足类似场景的参数化测试,我们需要按照以下步骤:

  • 对于测试方法的每一个输入都设置它预期的输出值
  • 使用入参计算出实际的执行结果
  • 比较预期值和实际值是否相符
    所以我们需要支持同时传入多个参数的数据源(同时传入入参值和预期值),@CsvSource就是其中一个
@ParameterizedTest
@CsvSource({"test,TEST", "tEst,TEST", "Java,JAVA"})
void toUpperCase_ShouldGenerateTheExpectedUppercaseValue(String input, String expected) {
    String actualValue = input.toUpperCase();
    assertEquals(expected, actualValue);
}

@CsvSource接收一个以逗号为分隔符的数组作为数据源,数组的每一组元素对应着一个CSV的一个记录,也就是输入参数和预期值 (CSV–Comma-Separated Values,有时也称为字符分隔值)

每次会从源中获取一对参数,以逗号为分隔符进行划分后,分别作为入参和预期值执行测试用例。默认情形下我们使用逗号作为分隔符,不过也可以使用delimiter 属性定义自己的分隔符

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

上述例子中以冒号作为分隔符,仍旧是一个CSV源。

4.5. CSV Files

如果不想在代码里直接写CSV源,也可以使用一个CSV文件。

举例来说,我们可以定义如下一个CSV文件:

input,expected
test,TEST
tEst,TEST
Java,JAVA

我们可以通过@CsvFileSource加载上述CSV文件, 可以通过numLinesToSkip 参数忽略最上面的列名

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

我们通过resources属性指定想要执行的CSV文件路径,可以同时指定多个文件
numLinesToSkip 属性表示在执行CSV文件的时候我们需要跳过的行数。不指定的话,默认情形下,@CsvFileSource会执行对应文件的每一行。不过通常我们会用这个属性跳过CSV文件的列名,就像上面的例子这样

跟@CsvSource注解一样,我们也可以通过delimiter属性自定义分隔符

除了每一行的分隔符,我们还能通过lineSeparator自定义行分隔符,默认的行分隔符是换行符"\n"; 也可以通过encoding属性定义文件的编码格式,默认采用"UTF-8"

4.6. Method

上述提到的参数源都比较简单,而且有一个共同的缺陷:那就是非常困难或者无法去构建复杂对象的参数化测试

想要提供更复杂的参数去满足参数化测试其中一个办法就是使用一个方法作为参数源
让我们使用@MethodSource验证下isBlank方法

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

我们提供给 @MethodSource 的参数值需要是一个有效的方法名
所以接下来我们需要编写一个provideStringsForIsBlank方法,定义一个返回参数流的静态方法(注意一定要是静态方法)

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

这里我们返回了一个参数的stream,但是并不是所有的方法返回值都需要这个。举例来说,我们可以返回任何集合的结果(eg.List)

如果我们仅需要在每个测试用例执行的时候提供的参数类型都是一种,我们也可以不使用Arguments,而直接使用对应类型,如下:

@ParameterizedTest
@MethodSource // hmm, no method name ...
void isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument(String input) {
    assertTrue(Strings.isBlank(input));
}

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

当我们没有在@MethodSource注解里指定方法名称的时候,JUnit会检索当前测试类,找到和当前测试方法同名的方法作为方法源

在某些时候,我们需要在不同的测试类之间共享一些参数,此时我们就可以在@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, "", "  ");
    }
}

使用#号隔开类的全限定名和方法名,我们就能指定非本类的静态方法组作为方法源

4.7. 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("   ") 
        );
    }
}

定义好参数类之后,我们就可以在测试用例上添加@ArgumentsSource注解指定参数类

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

接下来让我们通过自定义注解的这一更简洁的方式来实现自定义参数源

4.8. Custom Annotation

如果我们通过一个静态变量来加载测试参数呢?就像下面这样

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

但是实际上,JUnit 5并不支持这种写法。不过我们可以自己实现一下

首先,我们可以创建一个注解,如下:

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(VariableArgumentsProvider.class)
public @interface VariableSource {
 
    /**
     * The name of the static variable
     */
    String value();
}

接着,我们需要想办法去获取到注解里的参数信息并执行参数测试。JUnit 5提供两个接口以实现上述需求 :

  • AnnotationConsumer接口提供方法获取注解里的信息
  • ArgumentsProvider帮助我们提供测试参数

所以,我们接下来要做的就是定义一个实现了上述接口的VariableArgumentsProvider类,获取指定的静态变量作为参数化测试的参数

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();
    }
    //从测试类根据参数名称获取Field字段信息
    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 {
            field.setAccessible(true);
            value = field.get(null);
        } catch (Exception ignored) {}
 
        return value == null ? null : (Stream<Arguments>) value;
    }
}

这样就可以执行了,是不是很神奇!

5. 参数转换Argument Conversion

5.1. 隐式转换

下面写了一个使用@CsvSource的参数化测试,不过实际使用到的参数是枚举类Month

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

按理说这应该执行错误,毕竟枚举值和@CsvSource的定义格式不一样。但事实上这个参数是可以正常通过的

所以,JUnit 5会将字符串类型的参数转化成对应的枚举类型。为了支持这种情形,JUnit Jupiter提供了一系列隐式的类型转换器

转换过程取决于对应方法声明的参数类型,这个隐式转换可以将字符串对象转换成下列类型 :

  • UUID
  • Locale
  • LocalDate, LocalTime, LocalDateTime, Year, Month, etc.
  • File and Path
  • URL and URI
  • Enum subclasses
5.2. 显式转换

有时候我们需要一个自定义的显式转换器来做参数转换

假设我们需要把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(XXX.class) 来指定转换器

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

6. 参数构造器Argument Accessor

默认情形下,提供给参数化测试的每个参数都是一个独立的方法参数。因此,如果我们想要验证一个复杂参数的参数源,这个方法的参数列表就会变得很大而且难以理解

一种解决办法就是把所有的参数构造成ArgumentsAccessor 的实例并且通过索引和类型定位参数

比方说,下面有一个Person的类:

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,和预期的fullName. 我们可以使用ArgumentsAccesso来给测试方法的参数赋值而不需要声明每一个参数,如下:

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

这里,我们根据所有需要的参数构建一个ArgumentsAccessor实例,然后在测试方法的方法体内,根据每个参数的下标取得对应的参数。另外对于这个简单的构造器,可以通过getXX方法进行类型转换.

  • getString(index) 根据指定下标获取元素并转换成String类型,其余的getXX方法类型,都可以转换成对应的类型
  • get(index) 根据指定下标获取一个Object元素,用户自己进行类型转化
  • get(index, type) 根据指定下标获取元素后再转化成指定的类型

7. 参数聚合Argument 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来代替最后的三个参数并且通过这三个参数实例化Persion对象。

8. 自定义展示名称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));
}

这样执行参数化测试展示的名称可读性会更好,如下:

├─ 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开始,第一个执行的下标为1,第二个为2 这样
  • {arguments} 占位符用来表示以逗号为分隔符的参数集合
  • {0}, {1}, … 是单独参数的占位符

9. 结论

在这篇文章里,我们针对JUnit 5的参数化测试的细节进行了深度研究。

我们认识到参数化测试和普通的单元测试不太一样,主要体现在两方面:

  1. 测试方法需要添加@ParameterizedTest注解
  2. 需要一个声明参数的数据源

当然,目前为止我们知道JUnit提供了很多工具帮助我们将参数转换成自定义的目标类型或者使用自定义的测试名字。

同样的,上述示例代码可在我们的GitHub项目上找到,你们可以自行去下载测试!

第一次翻译,有些术语可能翻译的不是很好,有问题的地方欢迎指正!

原文链接:
Guide to JUnit 5 Parameterized Tests

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值