Java JUnit5 单元测试参数化
本文部分代码来自 JUint 官方文档
TL;DR
JUnit5 可以通过 ParameterizedTest
和 SourceArguments 两个 annotation 来参数化一个单元测试。
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
public class Junit5Test {
@ParameterizedTest
@ValueSource(ints = {1, 0, -1, Integer.MIN_VALUE, Integer.MAX_VALUE})
void parameterizedTest(int num) {
assertEquals("" + num, String.valueOf(num));
}
}
而上方代码中的 @ValueSource
就是 Source Argument,用于标注单元测试的参数。每个参数通过测试方法的参数传入。
@ValueSource 除了支持 ints 外,还支持:
short
byte
int
long
float
double
char
boolean
java.lang.String
java.lang.Class
除了 @ValueSource
之外,JUnit5 还提供了其他的 SourceArguments。例如:
@NullSource
@EmptySource
@MethodSource
@CsvSource
@ArgumentsSource
详细用法可以参考后文或者官方文档:https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests-sources
什么是参数化
参数化的意识是,使得一个单元测试可以根据外部参数执行不同的测试。在编码单元测试时,一个频繁遇到的场景是,一段代码你需要测试各种不同的场景。
例如,如果你的代测代码接受数字作为输入,那么通常你需要测试,0、正数、负数、最大值、最小值。
或者,如果你的代测代码接受 List 作为输入,那么你就需要测试 null, 空 List、只有一个 Item 和有多个 Item 的情况。
如果单元测试无法参数化,你可能不得不复制粘贴好几次,把同一段单元测试代码复制几次,再修改相应的输入数据。又或者,你可以选择写个循环,遍历你的测试数据。
然而这样做都不够方便。第一种方法重复劳动太多,不符合 DRY 的代码原则。第二种方法,若中间的某个测试数据测出了问题,不能直观地看到是哪一组数据测出了问题。
而 JUnit5 的单元测试参数化,就是解决这样的困扰。在上方的 TL;DR 中,我通过 @ParameterizedTest 和 @ValueSource 定义了一个单元测试的输入数据。如果你在 IDE 里运行,你可以看到没一组数据都单独列出来,非常清晰明了。

参数化一组测试数据
你可以通过 @ValueSource 定义一组简单的测试数据。 @ValueSource 支持 8 中基本类型、String 字符串和 Class 类型。
short
byte
int
long
float
double
char
boolean
java.lang.String
java.lang.Class
下方代码演示了如何定义一组 Class 类型的测试参数。
@ParameterizedTest
@ValueSource(classes = {String.class, TimeUnit.class, RuntimeException.class})
void parameterizedTestWithClasses(Class clazz) {
System.out.println(clazz.getCanonicalName());
}
参数化 Null 和 Empty 值
JUnit5 提供了关于 null 值和空值的数据源——@NullSource、@EmptySource 和 @NullAndEmptySource。 其中空值是指: 空字符串: "" 空数组: String[]、int[] 甚至二维数组 char[][] 空容器: 空的 List, Set 或者 Map @NullAndEmptySource 为 @NullSource 和 @EmtpySource 的组合 Annotation。
当然,通过 Annotation 去定义一个空的测试参数似乎有点鸡肋。这三个数据源的主要用途是与 @ValueSource 一起使用。由于 Java 语法的本身限制,@ValueSource 是无法接受 null 值的,而 null 和 empty 又是单元测试中必不可少的 boundry case。为了解决这个问题,@NullSource、@EmptySource 和 @NullAndEmptySource 应运而生。
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"A", "B", "C"})
void parameterizedTestForString(String str) {
System.out.println(str);
}
参数化枚举数据
枚举,也是单元测试的常客。你的 if 和 switch 肯定经常与 Enum 配合使用,对吧?
@ParameterizedTest
@EnumSource
void parameterizedTestForEnum(TimeUnit unit) {
System.out.println(unit.name());
}
JUnit5 提供了 @EnumSource。@EnumSource 会根据方法的输入参数判断你要测试的枚举类型。然后注入所有的枚举值。上方的例子中,JUnit5 会生成 TimeUnit 的 NANOSECONDS、MICROSECONDS、MILLISECONDS等一共7个测试用例。
如果你不想测试所有枚举值,@EnumSource 接受 names 参数,用于指定需要测试的枚举值。
@ParameterizedTest
@EnumSource(names = { "DAYS", "HOURS" })
void parameterizedTestForEnum(TimeUnit unit) {
System.out.println(unit.name());
}
而有时候,枚举的值太多,相比指定你想测试什么,你可能更想指定你不想测试什么。@EnumSource 提供了 mode 参数。该参数指定你想如何匹配枚举值。mode 支持 INCLUDE、EXCLUDE、MATCH_ALL 或 MATCH_ANY。嗯,顾名思义,不多做解释。
参数化 CSV 格式的数据
上述的众多 Argument Source,有一个比较尴尬的缺陷。它们只能指定一维的测试数据。如果你想指定多维的数据,或者你想指定输入数据和测试结果,事情就比较尴尬了。为了解决这个问题,你可以使用 @CsvSource 。@CsvSource 接受多行 csv 格式的数据,并根据 csv 生成单元测试。
@ParameterizedTest
@CsvSource({
"Apple , 5 ",
"Banana , 6 ",
"Watermelon, 10"
})
void parameterizedTestForCsv(String word, int len) {
assertEquals(len, word.length());
}
代码相当直观,不用多做解释。但有几点需要注意
如果数据带有逗号,你需要用单引号括起来:
@CsvSource({ "apple, 'lemon, lime'" })
空字符串
@CsvSource({ "apple, ''" })
null 值
@CsvSource({ "apple, " }) 或者指定 nullValues 参数: @CsvSource(value = { "apple, banana, NIL" }, nullValues = "NIL")
CSV 文件
你还可以指定 CSV 文件:
@CsvFileSource(resources = "/two-column.csv", numLinesToSkip = 1)
参数 numLinesToSkip 指定跳过文件内容的多少行。一般地, CSV 文件的第一行为列名,而不是实际数据。
自定义数据源
为了应对更复杂的需求,JUnit5 提供了更灵活的 Argument Source 接口, @ArgumentsSource。我们可以通过在 @ArgumentsSource 中指定一个 ArgumentsProvider 的实现类,该类通过实现 provideArguments 方法提供任意你需要的测试参数。
@ParameterizedTest
@ArgumentsSource(MyArgumentsProvider.class)
void testWithArgumentsSource(String argument) {
assertNotNull(argument);
}
public class MyArgumentsProvider implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
return Stream.of("apple", "banana").map(Arguments::of);
}
}
参数转换
JUnit5 提供了各种形式的参数类型转换能力。自动类型转换能让你更随心所意的编写测试,而不用被 Java 的强类型特性所扰。
自动拓宽数据类型
数据类型可以自动向上拓宽。例如 int 可以被自动转换成 long、float 或者 double。具体的转换规则参见: https://docs.oracle.com/javase/specs/jls/se8/html/jls-5.html#jls-5.1.2
隐式类型转换
JUnit5 还提供了各种方便的隐式类型转换。例如:
@ParameterizedTest
@ValueSource(strings = "SECONDS")
void testWithImplicitArgumentConversion(ChronoUnit argument) {
assertNotNull(argument.name());
}
在上述类型中,字符串 "SECONDS" 会被自动转换成 ChronoUnit。下方是 JUnit5 官方提供的内建类型转换表。
Target Type | Example |
---|---|
boolean | "true" |
byte | "15" |
char | "o" |
short | "15" |
int | "15" |
long | "15" |
float | "1.0" |
double | "1.0" |
Enum | "SECONDS" |
java.io.File | "/path/to/file" |
java.lang.Class | "java.lang.Integer" |
java.lang.Class | "byte" |
java.lang.Class | "char[]" |
java.math.BigDecimal | "123.456e789" |
java.math.BigInteger | "1234567890123456789" |
java.net.URI | "https://junit.org/" |
java.net.URL | "https://junit.org/" |
java.nio.charset.Charset | "UTF-8" |
java.nio.file.Path | "/path/to/file" |
java.time.Duration | "PT3S" |
java.time.Instant | "1970-01-01T00:00:00Z" |
java.time.LocalDateTime | "2017-03-14T12:34:56.789" |
java.time.LocalDate | "2017-03-14" |
java.time.LocalTime | "12:34:56.789" |
java.time.MonthDay | "--03-14" |
java.time.OffsetDateTime | "2017-03-14T12:34:56.789Z" |
java.time.OffsetTime | "12:34:56.789Z" |
java.time.Period | "P2M6D" |
java.time.YearMonth | "2017-03" |
java.time.Year | "2017" |
java.time.ZonedDateTime | "2017-03-14T12:34:56.789Z" |
java.time.ZoneId | "Europe/Berlin" |
java.time.ZoneOffset | "+02:30" |
java.util.Currency | "JPY" |
java.util.Locale | "en" |
java.util.UUID | "d043e930-7b3b-48e3-bdbe-5a3ccfb833db" |
字符串到对象的转换
你还可以定义更多的类型转换。JUnit5 支持隐式和显式两种风格的类型转换定义。
自定义隐式类型转换
这种方式的类型转换和上方 JUnit 自己提供的转换类似,都是把 String 自动转换成其他类型对象。
当 JUnit5 发现你提供了 String 类型的参数,却需要其他类型的实际数据时。如果该类定义了:
- 非私有且静态的工厂方法,该方法接受一个字符串,且返回需要的对象实例。
- 非私有的构造函数,其接受一个字符串,且返回需要的对象实例。
则 Junit5 会自动帮你把字符串转换成对应类型的实例。
@ParameterizedTest
@ValueSource(strings = "42 Cats")
void testWithImplicitFallbackArgumentConversion(Book book) {
assertEquals("42 Cats", book.getTitle());
}
public class Book {
private final String title;
private Book(String title) {
this.title = title;
}
public static Book fromTitle(String title) {
return new Book(title);
}
public String getTitle() {
return this.title;
}
}
上方的代码演示了 “42 Cats” 通过静态的 fromTitle() 转换 Book 实例。
自定义显式类型转换
嗯...不多说了,直接上代码吧。
@ParameterizedTest
@EnumSource(ChronoUnit.class)
void testWithExplicitArgumentConversion(
@ConvertWith(ToStringArgumentConverter.class) String argument) {
assertNotNull(ChronoUnit.valueOf(argument));
}
public class ToStringArgumentConverter extends SimpleArgumentConverter {
@Override
protected Object convert(Object source, Class<?> targetType) {
assertEquals(String.class, targetType, "Can only convert to String");
if (source instanceof Enum<?>) {
return ((Enum<?>) source).name();
}
return String.valueOf(source);
}
}
public class ToLengthArgumentConverter extends TypedArgumentConverter<String, Integer> {
protected ToLengthArgumentConverter() {
super(String.class, Integer.class);
}
@Override
protected Integer convert(String source) {
return source.length();
}
}
参考文献
https://junit.org/junit5/docs/current/user-guide/\#writing-tests-parameterized-tests