Junit5中的参数化测试(Parameterized Tests)指南
作为新一代的测试框架,Junit5中有很多大家喜欢的测试方案,个人认为最突出的就是能够进行参数化的测试(Parameterized Tests)。简介通常,会遇到这样的情况,同一个测试案例,改变的只是测试时候输入的参数不同。按照之前的做法,可能会是通过每个输入参数都写一个测试,或者将测试参数封装到集合中循环遍历执行测试。在新版的Junit5中,已经提供了一种更加优雅的方式来进行。该特性允许我...
作为新一代的测试框架,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。
参考文档
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)