Spring Boot 单元测试(二)参数化测试

Spring Boot 使用 JUnit5 提供的 @ParameterizedTest 注解实现参数化测试,同时要配合其它注解完成参数源配置。

一、自定义测试执行名称

@ParameterizedTest 默认的测试执行名称格式为 [序号]参数1=XXX, 参数2=YYY... ,可以通过修改 name 属性自定义测试执行名称。

@ParameterizedTest(name = "第 {index} 次测试,参数:{0}")
@ValueSource(ints = { 1, 10, 100 })
public void test(int value) {
    Assertions.assertTrue(value < 100);
}

测试执行名称为:

第 1 次测试,参数:1
第 2 次测试,参数:10
第 3 次测试,参数:100

如果参数有多个,则依次为: {0}{1}{2}

二、参数数据源

1. @ValueSource

@ValueSource 数据源支持以下类型的数组:

  • short
  • byte
  • int
  • long
  • float
  • double
  • char
  • java.lang.String
  • java.lang.Class

int 类型为例,以下方法会测试 3 次,第 3 次参数为 100 时测试结果为 Fail。

@ParameterizedTest
@ValueSource(ints = { 1, 10, 100 })
public void test(int value) {
    Assertions.assertTrue(value < 100);
}

2. @NullSource

在使用字符串作为入参时,有时可能会用到 null ,不能直接将 null 写入 @ValueSource 注解的 strings 数组中(编译器会报错)。

@ValueSource(strings = { null, "X", "Y", "Z" })

正确的方法是使用 @NullSource 注解。

@ParameterizedTest
@NullSource
@ValueSource(strings = { "X", "Y", "Z" })
public void test(String value) {
    System.out.println("Param: " + value);
}

3. @EmptySource

@NullSource 类似,使用字符串作为入参时如果需要使用空字符串,可以使用 @EmptySource

@ParameterizedTest
@EmptySource
@ValueSource(strings = { "X", "Y", "Z" })
public void test(String value) {
    System.out.println("Param: " + value);
}

@NullSource 不同的是,可以直接在 @ValueSource 注解的 strings 数组中写空字符串参数,编译器不会报错。

@ValueSource(strings = { "", "X", "Y", "Z" })

4. @NullAndEmptySource

如果要同时使用 null 和空字符串作为测试方法的入参,可以使用 @NullAndEmptySource 注解。

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { "X", "Y", "Z" })
public void test(String value) {
    System.out.println("Param: " + value);
}

5. @EnumSource

@EnumSource 是枚举数据源,可以让一个枚举类中的全部或部分值作为测试方法的入参。

  1. 先定义一个枚举类
public enum Type {
    ALPHA, BETA, GAMMA, DELTA
}
  1. 在测试方法上添加 @EnumSource 注解,JUnit 根据测试方法参数类型判断使用哪个枚举。
@ParameterizedTest
@EnumSource
public void test(Type type) {
    System.out.println("Param: " + type);
}
  1. 如果只想使用枚举中的部分值,可以在 @EnumSource 注解的 names 属性中指定,如果 names 属性包含不存在的枚举值则运行时会报错。
@ParameterizedTest
@EnumSource(names = { "BETA", "DELTA" })
public void test(Type type) {
    System.out.println("Param: " + type);
}
  1. 也可以通过设置 @EnumSource 注解的 mode 属性为 EnumSource.Mode.EXCLUDE 指定不使用哪些枚举值。
@ParameterizedTest
@EnumSource(mode = Mode.EXCLUDE, names = { "BETA", "DELTA" })
public void test(Type type) {
    System.out.println("Param: " + type);
}

6. @MethodSource

@MethodSource 注解可以指定一个方法名称,使用该方法返回的元素集合作为测试方法的入参,方法必须返回 Stream 类型。

@ParameterizedTest
@MethodSource("paramProvider")
public void test(String param) {
    System.out.println("Param: " + param);
}

public static Stream<String> paramProvider() {
    return Stream.of("X", "Y", "Z");
}

以上是个静态方法,且此方法与测试方法在同一个类中,如果不在同一个类中则需要指定静态方法的全路径: package.类名#方法名

@ParameterizedTest
@MethodSource("com.example.demo.DemoUnitTest#paramProvider")
public void test(String param) {
    System.out.println("Param: " + param);
}

如果参数数据源不是静态方法而是实例方法,则需要使用 @TestInstance 注解。

7. @CsvSource

以上数据源都只针对一个参数的测试方法, @CsvSource 可以处理多个参数的测试方法。

@ParameterizedTest
@CsvSource({
        "2021/12/01, Wednesday, Sunny",
        "2021/12/10, Friday, Rainy",
        "2021/12/13, Monday, Chilly"
})
public void test(String date, String dayOfWeek, String weather) {
    System.out.println(date + " -- " + dayOfWeek + " -- " + weather);
}

@CsvSource 注解提供了一个 nullValues 属性,可以将指定字符串替代成 null

@ParameterizedTest
@CsvSource(value = {
        "2021/12/01, Wednesday, Sunny",
        "2021/12/10, Friday, ",
        "2021/12/13, Monday, Chilly"
}, nullValues = "")
public void test(String date, String dayOfWeek, String weather) {
    System.out.println(date + " -- " + dayOfWeek + " -- " + weather);
}

8. @CsvFileSource

测试数据量大时直接将测试数据写入源文件不太合适,可以使用 @CsvFileSource 代替 @CsvSource ,指定对应的 csv 文件作为数据源,可以使用 numLinesToSkip 属性指定跳过的行数(跳过表头)。

@ParameterizedTest
@CsvFileSource(files = "src/test/resources/data.csv", numLinesToSkip = 1, delimiter = ',', nullValues = "")
public void test(String date, String dayOfWeek, String weather) {
    System.out.println(date + " -- " + dayOfWeek + " -- " + weather);
}

CSV 文件如下:

日期,星期几,天气
2021/12/1,Wednesday,Sunny
2021/12/10,Friday,
2021/12/13,Monday,Chilly

9. @ArgumentsSource

如果以上数据源都不能满足测试需求,可以开发实现 ArgumentsProvider 接口的实现类作为自定义参数数据源。

package com.example.demo;

import java.util.stream.Stream;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;

public class CustomArgumentsProvider implements ArgumentsProvider {

    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext arg0) throws Exception {
        return Stream.of(Arguments.of(1), Arguments.of("2"), Arguments.of(true));
    }
}

然后使用 @ArgumentsSource 指定数据源。

@ParameterizedTest
@ArgumentsSource(CustomArgumentsProvider.class)
public void test(Object param) {
    System.out.println(param);
}

三、参数转换

如果参数数据源的数据类型和测试方法参数的数据类型不一致,可以指定类型转换器进行数据类型转换。

@ParameterizedTest
@ValueSource(strings = { "2021/12/01", "2021/12/10" })
public void test(@JavaTimeConversionPattern("yyyy/MM/dd") LocalDate date) {
    System.out.println(date);
}

以上代码将数据源的字符串类型通过转换器转换成了日期类型。

1. 自定义参数转换器

自定义参数转换器需要实现 ArgumentConverter 接口。

package com.example.demo;

import java.util.regex.Pattern;

import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.params.converter.ArgumentConversionException;
import org.junit.jupiter.params.converter.ArgumentConverter;

public class CustomConversionPattern implements ArgumentConverter {

    @Override
    public Object convert(Object arg0, ParameterContext arg1) throws ArgumentConversionException {
        boolean isInteger = Pattern.matches("^[1-9][0-9]*$", arg0.toString());
        if (!isInteger) {
            throw new IllegalArgumentException();
        }
        return Integer.valueOf(arg0.toString());
    }
}

在测试方法参数前使用 @ConvertWith 注解制定自定义的参数转换器。

@ParameterizedTest
@ValueSource(strings = { "1", "10", "100" })
public void test(@ConvertWith(CustomConversionPattern.class) Integer value) {
    System.out.println(value);
}

四、字段聚合

如果数据源中每条数据有多个字段,按照之前所示需要在测试方法中定义与字段数量相等的参数,非常不方便,可以通过 ArgumentsAccessor 获取数据源中所有字段,CSV 字段实际上存储在 ArgumentsAccessor 实例内部的一个 Object 数组中。

@ParameterizedTest
@CsvSource({
        "2021/12/01, Wednesday, Sunny",
        "2021/12/10, Friday, Rainy",
        "2021/12/13, Monday, Chilly"
})
public void test(ArgumentsAccessor argumentsAccessor) {
    LocalDate date = LocalDate.parse(argumentsAccessor.getString(0), DateTimeFormatter.ofPattern("yyyy/MM/dd"));
    String dayOfWeek = argumentsAccessor.getString(1);
    String weather = argumentsAccessor.getString(2);
    System.out.println(date + " -- " + dayOfWeek + " -- " + weather);
}

1. 自定义参数聚合器

将 CSV 中每条数据封装成一个类对象。

package com.example.demo;

import java.time.LocalDate;

public class TestData {

    private LocalDate date;

    private String dayOfWeek;

    private String weather;

    // Getter Setter 略
}

自定义聚合器。

package com.example.demo;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.params.aggregator.ArgumentsAccessor;
import org.junit.jupiter.params.aggregator.ArgumentsAggregationException;
import org.junit.jupiter.params.aggregator.ArgumentsAggregator;

public class TestDataAggregator implements ArgumentsAggregator {

    @Override
    public Object aggregateArguments(ArgumentsAccessor arg0, ParameterContext arg1)
            throws ArgumentsAggregationException {
        TestData testData = new TestData();
        testData.setDate(LocalDate.parse(arg0.getString(0), DateTimeFormatter.ofPattern("yyyy/MM/dd")));
        testData.setDayOfWeek(arg0.getString(1));
        testData.setWeather(arg0.getString(2));
        return testData;
    }
}

使用 @AggregateWith 注解指定聚合器。

package com.example.demo;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.aggregator.AggregateWith;
import org.junit.jupiter.params.provider.CsvSource;

public class DemoUnitTest {

    @ParameterizedTest
    @CsvSource({
            "2021/12/01, Wednesday, Sunny",
            "2021/12/10, Friday, Rainy",
            "2021/12/13, Monday, Chilly"
    })
    public void test(@AggregateWith(TestDataAggregator.class) TestData testData) {
        System.out.println(testData.getDate() + " -- " + testData.getDayOfWeek() + " -- " + testData.getWeather());
    }
}

2. 自定义聚合器注解

将上一步中 @AggregateWith(TestDataAggregator.class) 封装成自定义注解。

package com.example.demo;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.junit.jupiter.params.aggregator.AggregateWith;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AggregateWith(TestDataAggregator.class)
public @interface CsvToTestData {
}

使用自定义聚合器注解。

@ParameterizedTest
@CsvSource({
        "2021/12/01, Wednesday, Sunny",
        "2021/12/10, Friday, Rainy",
        "2021/12/13, Monday, Chilly"
})
public void test(@CsvToTestData TestData testData) {
    System.out.println(testData.getDate() + " -- " + testData.getDayOfWeek() + " -- " + testData.getWeather());
}

作者:又语
原文:Spring Boot 单元测试(二)参数化测试 - 掘金