参数化测试
重要性:★★★★☆
有时候,为了能够全面证明代码的正确性,我们需要使用多组不同的数据去测试同一个方法(例如用不同的取款金额去测试取款的结果)。如果针对每组数据分别写一个测试方法,就会非常繁琐。
通过使用@ParameterizedTest
注解取代@Test
注解,我们可以使用不同的参数值多次调用同一个测试方法,这就是参数化测试。当执行参数化测试的时候,还需要至少定义一个参数源,用来为测试方法提供参数值。
下面是简单的参数化测试例子:
@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
assertTrue(StringUtils.isPalindrome(candidate));
}
1. 添加依赖项
要编写参数化测试,必须在项目中添加junit-jupiter-params
依赖项。
在maven项目中,需要在pom.xml
文件中的<dependencies>
节添加下面的依赖:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.6.2</version>
<scope>test</scope>
</dependency>
在gradle项目中,需要在build.gradle
文件中的dependencies
节添加以下内容:
testCompile 'org.junit.jupiter:junit-jupiter-params:5.6.2'
如果项目中已经定义了junit-jupiter
依赖项,就不需要添加junit-jupiter-params
依赖项了。因为前者对后者有传递性依赖。
2. 定义参数源
JUnit Jupiter提供了一些内建的参数源注解。
2.1 @ValueSource
通过指定一个由简单值字面量组成的数组提供参数源。当使用@ValueSource
时,测试方法只能有一个来自参数源的参数(依赖注入的其他参数不算)。
@ValueSource
支持以下的数据类型:
- short
- byte
- int
- long
- float
- double
- char
- boolean
- java.lang.String
- java.lang.Class
例如,下面的代码示例会分别以1,2,3作为参数值调用参数化测试方法testWithValueSource()
各一次。
package yang.yu.tdd.parameterized;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
public class ParameterizedDemo {
@ParameterizedTest
@ValueSource(ints = { 1, 2, 3 })
void testWithValueSource(int argument) {
assertThat(argument).isGreaterThan(0).isLessThan(4);
}
}
2.2 @NullSource
、@EmptySource
和@NullAndEmptySource
为了测试被测类方法在接收各种“坏”输入值时方法的行为,我们要给参数化测试方法提供能够提供代表null和空值的参数值。
@NullSource
:给参数化测试方法提供null作为参数值。这个注解不能用于提供基本类型的值。@EmptySource
:为参数化测试方法提供代表空(empty)
的值作为参数值。支持以下类型:java.lang.String
,java.util.List
,java.util.Set
,java.util.Map
, 基本类型数组 (例如int[]
,char[][]
, 等等), 对象数组 (例如String[]
,Integer[][]
等等,但不支持上述类型的子类型。对于字符串,会提供空字符串;对于各种集合、Map和数组,提供不包含任何元素的空集合、空Map和空数组。@NullAndEmptySource
:包含了@NullSource
和@EmptySource
的组合注解。
上述几个注解都只能应用在仅接收一个来自参数源的参数的参数化测试方法上。
下面是代码示例:
@ParameterizedTest
@NullSource
@EmptySource
@ValueSource(strings = { " ", " ", "\t", "\n" })
void nullEmptyAndBlankStrings(String text) {
assertThat(text == null || text.trim().isEmpty()).isTrue();
}
上面的参数化方法分别使用参数null, "", " ", " ", "\t", "\n" 调用1次,一共6次。@NullSource
提供第1个参数null,@EmptySource
提供了第2个参数"",@ValueSource
提供了其余的4个参数。参数化方法上注解出现顺序决定了参数的顺序。
如果去掉@NullSource
和@EmptySource
,换成@NullAndEmptySource
:
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { " ", " ", "\t", "\n" })
void nullEmptyAndBlankStrings2(String text) {
assertThat(text == null || text.trim().isEmpty()).isTrue();
}
执行结果和上面一样。这说明@NullAndEmptySource
两次提供了参数,第一次是null,第二次是""。
2.3 @EnumSource
@EnumSource
注解提供一个枚举类型的全部或部分枚举值来为参数化测试方法提供参数值。
@ParameterizedTest
@EnumSource
void testWithEnumSourceWithAutoDetection(ChronoUnit unit) {
System.out.println(unit);
}
@ParameterizedTest
@EnumSource(ChronoUnit.class)
void testWithEnumSource(TemporalUnit unit) {
System.out.println(unit);
}
@EnumSource
注解的值可以忽略掉。当没有给@EnumSource
注解指定值时,会使用参数化测试的第一个参数的声明类型。上面的testWithEnumSource()
方法必须给@EnumSource
注解指定值,因为TemporalUnit
不是枚举类型,而作为TemporalUnit
接口的实现,ChronoUnit
是枚举类型。
如果只想使用枚举类型中的部分枚举值,可以定义@EnumSource
注解的names
属性,包含那些要作为参数化方法的参数的枚举值:
@ParameterizedTest
@EnumSource(names = { "DAYS", "HOURS" })
void testWithEnumSourceInclude(ChronoUnit unit) {
assertThat(unit).isIn(ChronoUnit.DAYS, ChronoUnit.HOURS);
}
还可以通过定义@EnumSource
注解的mode
属性,微调枚举值的筛选方法。它有4个取值:
- Mode.INCLUDE:默认选项。包含
names
属性中定义的枚举值。 - Mode.EXCLUDE:排除
names
属性中定义的枚举值。 - Mode.MATCH_ANY:当
names
是一组正则表达式时,返回匹配这些表达式之一的枚举值 - Mode.MATCH_ALL:当
names
是一组正则表达式时,返回匹配全部这些表达式的枚举值
@ParameterizedTest
@EnumSource(mode = EnumSource.Mode.MATCH_ANY, names = "^.*DAYS$")
void testWithEnumSourceRegex(ChronoUnit unit) {
assertThat(unit.name()).endsWith("DAYS");
}
2.4 @MethodSource
@MethodSource
注解使你可以调用测试类或外部类中的工厂方法来获得参数化测试方法的参数值。
如果工厂方法来自测试类,除非采用了PER_CLASS
生命周期,否则这个方法必须是静态的;如果工厂方法来自外部类,它必须是静态的。这些工厂方法必须没有任何参数。
每个工厂方法必须能够生成一个由参数集组成的流,每个参数集中各个参数值按顺序提供给参数化方法的各个参数。这里所说的“流”是指所有可以被JUnit转换为Stream
类型的任何类型,如Stream
, DoubleStream
, LongStream
, IntStream
, Collection
, Iterator
, Iterable
,对象数组,原始类型数组,等等。流中的元素也可以作为Arguments
类的实例、对象数组、单个值(如果参数化测试方法只接受单个参数)等提供给参数化测试方法。
下面是单个参数的代码示例:
@ParameterizedTest
@MethodSource("stringProvider")
void testWithExplicitLocalMethodSource(String argument) {
assertThat(argument).isIn("apple", "banana");
}
static Stream<String> stringProvider() {
return Stream.of("apple", "banana");
}
如果你没有在@MethodSource
注解中指定工厂方法的名字,JUnit Jupiter将在测试类中寻找和参数化测试方法同名的方法作为工厂方法。下面是示例:
@ParameterizedTest
@MethodSource
void testWithDefaultLocalMethodSource(String argument) {
assertThat(argument).isIn("apple", "banana");
}
static Stream<String> testWithDefaultLocalMethodSource() {
return Stream.of("apple", "banana");
}
下面的代码演示用原生流作为参数源:
@ParameterizedTest
@MethodSource("range")
void testWithRangeMethodSource(int argument) {
assertThat(argument).isLessThan(20).isGreaterThan(9);
}
static IntStream range() {
return IntStream.range(0, 20).skip(10);
}
如果参数化测试方法声明多个参数,工厂方法必须返回以Arguments
类型的对象为元素的流(流、集合、数组等等)。下面是代码示例:
@ParameterizedTest
@MethodSource("stringIntAndListProvider")
void testWithMultiArgMethodSource(String str, int num, List<String> list) {
assertThat(str).hasSize(5);
assertThat(num).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2);
assertThat(list).hasSize(2);
}
static Stream<Arguments> stringIntAndListProvider() {
return Stream.of(
Arguments.arguments("apple", 1, Arrays.asList("a", "b")),
Arguments.arguments("lemon", 2, Arrays.asList("x", "y"))
);
}
下面是使用外部类的静态工厂方法的例子。首先定义一个类StringsProviders
package yang.yu.tdd.parameterized;
import java.util.stream.Stream;
public class StringsProviders {
public static Stream<String> tinyStrings() {
return Stream.of(".", "oo", "OOO");
}
}
然后定义参数化测试方法,引用这个类的tinyStrings()
方法来作为参数源:
@ParameterizedTest
@MethodSource("yang.yu.tdd.parameterized.StringsProviders#tinyStrings")
void testWithExternalMethodSource(String tinyString) {
assertThat(tinyString).isIn(".", "oo", "OOO");
}
请注意@MethodSource
注解的值是StringsProviders
类的tinyStrings()
方法的全限定名称。
2.5 @CsvSource
@CsvSource
注解允许你使用CSV形式给参数化方法提供参数:
@ParameterizedTest
@CsvSource({
"apple, 1",
"banana, 2",
"'lemon, lime', 0xF1"
})
void testWithCsvSource(String fruit, int rank) {
assertThat(fruit).isIn("apple", "banana", "lemon, lime");
assertThat(rank).isNotEqualTo(0);
}
@CsvSource
注解默认以逗号作为数据项分隔符,但可以通过delimiter
属性来改用其他字符做分隔符。也可以通过设定delimiterString
属性来用指定的字符串做数据项分隔符。
@CsvSource
注解使用单引号作为字符串界定符。例如上面例子中的'lemon, lime'。
''表示空字符串,除非设置了@CsvSource
注解的emptyValue
属性,那么一整个空字符串值将被作为null
值看待。
可以通过设置@CsvSource
注解的nullValues
属性,指定在CSV中出现的某些项作为null值看待。
注解 | 结果参数列表 |
---|---|
@CsvSource({ "apple, banana" }) |
"apple" , "banana" |
@CsvSource({ "apple, 'lemon, lime'" }) |
"apple" , "lemon, lime" |
@CsvSource({ "apple, ''" }) |
"apple" , "" |
@CsvSource({ "apple, " }) |
"apple" , null |
@CsvSource(value = { "apple, banana, NIL" }, nullValues = "NIL") |
"apple" , "banana" , null |
2.6 @CsvFileSource
@CsvFileSource
注解让你可以用类路径上的CSV文件来为参数化测试提供参数。
我们在类路径根目录下提供一个CSV文件two-column.csv
,内容如下:
Country, reference
Sweden, 1
Poland, 2
"United States of America", 3
下面是参数化测试方法:
@ParameterizedTest
@CsvFileSource(resources = "/two-column.csv", numLinesToSkip = 1, encoding = "UTF-8")
void testWithCsvFileSource(String country, int reference) {
assertThat(country).isIn("Sweden", "Poland", "United States of America");
assertThat(reference).isPositive();
}
注意在CSV文件中,是使用双引号而不是单引号作为字符串界定符的。
2.7 @ArgumentsSource
@ArgumentsSource
注解指定一个ArgumentsProvider
的实现类,通过该类的provideArguments
方法来为参数化测试方法提供参数。这个ArgumentsProvider
必须是顶层类或静态嵌套类。
下面是代码示例:
@ParameterizedTest
@ArgumentsSource(MyArgumentsProvider.class)
void testWithArgumentsSource(String argument) {
assertThat(argument).isIn("apple", "banana");
}
static class MyArgumentsProvider implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
return Stream.of("apple", "banana").map(Arguments::of);
}
}
3. 与其他参数共存
参数化测试方法和它的参数源提供的参数之间通常是直接一对一的关系(方法中的多个参数的出现顺序和参数源的参数出现顺序一一对应)。但是,参数化测试方法也可能从参数源聚合多个参数为一个对象传递给参数化方法的单个参数。另外参数化测试方法中还可能存在由参数解析器注入的另外的参数(例如TestInfo
和TestReporter
等)。
参数化测试方法声明形式参数必须遵循下面的规则:
- 最先声明0或多个索引的参数(由参数源提供实参的参数);
- 再声明0或多个聚合参数;
- 最后声明由参数解析器提供的参数。
4. 参数转换
4.1 拓宽转换
JUnit Jupiter在参数化测试中接收参数时支持对原生类型的拓宽转换(Widening Primitive Conversion)。例如,如果一个参数化测试方法被注解为@ValueSource(ints = { 1, 2, 3 })
,那么,这个测试方法可以声明的参数类型不仅仅限于int类型,还可以是long、float或double类型。
4.2 隐式转换
为了支持类似@CsvSource
这样的数据源,JUnit Jupiter提供了一些内建的隐式类型转换器。具体转换过程由参数化方法中每个参数的声明类型决定。
例如,如果一个参数化测试方法声明了一个TimeUnit
类型的参数,但是参数源提供的参数的实际类型是String
,那么这个字符串值将被自动转换成TimeUnit
相应的一个枚举值。
@ParameterizedTest
@ValueSource(strings = "SECONDS")
void testWithImplicitArgumentConversion(ChronoUnit argument) {
assertNotNull(argument.name());
}
字符串实例可以隐式转换到以下的目标类型:
4.3 工厂方法和工厂构造函数转换
如果参数化测试方法的参数类型声明了一个且只有一个工厂方法或工厂构造函数,那么JUnit Jupiter也会自动地将参数源提供的String
值转换成目标参数类型的实例。
- 工厂方法:在目标参数类型上声明的一个非私有的、静态的方法,接受一个字符串类型的参数并返回目标类型的实例。工厂方法名字是任意的,不需要遵守任何规则。
- 工厂构造函数:在目标参数类型上声明的一个非私有的构造函数,只接受一个字符串类型的参数。注意:目标;类型必须是顶层类或静态嵌套类。
如果同时存在多个工厂方法,则这些工厂方法都会被忽略。如果工厂方法和工厂构造函数共存,那么将使用工厂方法进行转换。
例如在下面的参数化测试方法代码示例中,将会通过调用Book.fromTitle()
静态工厂方法并传递字符串42 Cats
作为它的参数来创建Book类的实例,传递给参数化测试方法testWithImplicitFallbackArgumentConversion()
的形式参数book
。
@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;
}
}
4.4 显式转换
我们还可以对参数化测试声明参数显式转换。对于参数化测试中的一个参数:
- 定义一个
ArgumentConverter
,用来把其他类型的值转换成目标参数类型的值; - 在目标参数上添加注解
@ConvertWith
,引用上面的ArgumentConverter
。
ArgumentConverter
必须是顶层类或静态嵌套类。
下面给出一个例子。首先定义一个ArgumentConverter
接口的实现类,能够将其他类型的对象转换成字符串(如果是枚举类型的就转换成枚举值的名字,其他的就是对象的toString()结果):
package yang.yu.tdd.parameterized;
import org.junit.jupiter.params.converter.SimpleArgumentConverter;
public class ToStringArgumentConverter extends SimpleArgumentConverter {
@Override
protected Object convert(Object source, Class<?> targetType) {
if (source instanceof Enum<?>) {
return ((Enum<?>) source).name();
}
return String.valueOf(source);
}
}
下面就可以应用在参数化测试中(参数化测试方法接受String
类型的参数):
@ParameterizedTest
@EnumSource(ChronoUnit.class)
void testWithExplicitArgumentConversion(
@ConvertWith(ToStringArgumentConverter.class) String argument) {
System.out.println(argument);
}
junit-jupiter-params
工件中包含一个参数转换器JavaTimeArgumentConverter
,能够将字符串形式的日期/时间转换成LocalData/LocalTime/LocalDateTime
等。可以通过组合注解JavaTimeConversionPattern
使用它:
@ParameterizedTest
@ValueSource(strings = { "01.01.2017", "31.12.2017" })
void testWithExplicitJavaTimeConverter(
@JavaTimeConversionPattern("dd.MM.yyyy") LocalDate argument) {
assertThat(argument.getYear()).isEqualTo(2017);
}
5. 参数聚合
有时候,参数源提供的参数非常多,会导致参数化测试方法的方法签名非常长。这时候你可以在参数化测试方法中用一个参数聚合器ArgumentsAccessor
取代一整批参数。这种方式下同样支持参数的隐式转换。
下面是一个例子:
@ParameterizedTest
@CsvSource({
"Jane, Doe, F, 1990-05-20",
"John, Doe, M, 1990-10-22"
})
void testWithArgumentsAccessor(ArgumentsAccessor arguments) {
Person person = new Person(arguments.getString(0),
arguments.getString(1),
arguments.get(2, Gender.class),
arguments.get(3, LocalDate.class));
if (person.getFirstName().equals("Jane")) {
assertThat(person.getGender()).isEqualTo(Gender.F);
}
else {
assertThat(person.getGender()).isEqualTo(Gender.M);
}
assertThat(person.getLastName()).isEqualTo("Doe");
assertThat(person.getDateOfBirth().getYear()).isEqualTo(1990);
}
一个ArgumentsAccessor
实例会自动注入到参数化测试方法的ArgumentsAccessor
类型的任何参数中。
还可以定制自己的参数聚合器:
- 定义一个
ArgumentsAggregator
接口的实现类; - 在参数化测试方法中的兼容的参数上通过注解
@AggregateWith
关联这个自定义的参数聚合器。
参数聚合器必须是顶层类或静态嵌套类。
下面的代码示例定义一个PersonAggregator
,能够将ArgumentsAccessor
转换成Person
类实例:
package yang.yu.tdd.parameterized;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.params.aggregator.ArgumentsAccessor;
import org.junit.jupiter.params.aggregator.ArgumentsAggregator;
import java.time.LocalDate;
public class PersonAggregator implements ArgumentsAggregator {
@Override
public Person aggregateArguments(ArgumentsAccessor arguments, ParameterContext context) {
return new Person(arguments.getString(0),
arguments.getString(1),
arguments.get(2, Gender.class),
arguments.get(3, LocalDate.class));
}
}
这样你就能够在参数化测试方法中使用PersonAggregator
了:
@ParameterizedTest
@CsvSource({
"Jane, Doe, F, 1990-05-20",
"John, Doe, M, 1990-10-22"
})
void testWithArgumentsAggregator(@AggregateWith(PersonAggregator.class) Person person) {
if (person.getFirstName().equals("Jane")) {
assertThat(person.getGender()).isEqualTo(Gender.F);
}
else {
assertThat(person.getGender()).isEqualTo(Gender.M);
}
assertThat(person.getLastName()).isEqualTo("Doe");
assertThat(person.getDateOfBirth().getYear()).isEqualTo(1990);
}
如果你发现在代码中多次使用@AggregateWith(PersonAggregator.class)
,更好的方式是定义一个组合注解@CsvToPerson
,使用PersonAggregator
作为它的元注解:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AggregateWith(PersonAggregator.class)
public @interface CsvToPerson {
}
然后在参数化测试方法中定义Person类型的参数,注解为@CsvToPerson
:
@ParameterizedTest
@CsvSource({
"Jane, Doe, F, 1990-05-20",
"John, Doe, M, 1990-10-22"
})
void testWithCustomAggregatorAnnotation(@CsvToPerson Person person) {
if (person.getFirstName().equals("Jane")) {
assertThat(person.getGender()).isEqualTo(Gender.F);
}
else {
assertThat(person.getGender()).isEqualTo(Gender.M);
}
assertThat(person.getLastName()).isEqualTo("Doe");
assertThat(person.getDateOfBirth().getYear()).isEqualTo(1990);
}
6. 定制显示名
可以类似下面的示例那样定义参数化测试方法的显示名:
@DisplayName("Display name of container")
@ParameterizedTest(name = "{index} ==> the rank of ''{0}'' is {1}")
@CsvSource({ "apple, 1", "banana, 2", "'lemon, lime', 3" })
void testWithCustomDisplayNames(String fruit, int rank) {
}
执行测试后输出如下:
Display name of container ✔
├─ 1 ==> the rank of 'apple' is 1 ✔
├─ 2 ==> the rank of 'banana' is 2 ✔
└─ 3 ==> the rank of 'lemon, lime' is 3 ✔
在定制的显示名中,支持以下的占位符:
占位符 | 描述 |
---|---|
{displayName} |
方法的显示名 |
{index} |
当前调用次数(从1开始) |
{arguments} |
逗号分隔的完整参数列表 |
{argumentsWithNames} |
逗号分隔的带参数名的完整参数列表 |
{0} , {1} , … |
具体的参数 |
7. 生命周期和互操作性
参数化测试的每一次调用与注解为@Test
的测试方法的生命周期是一样的。在参数化测试的每次调用的前后都会分别执行@BeforeEach
和@AfterEach
方法。
参数化测试方法和普通测试方法可以共存于同一个测试类中。