添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

有点纠结要不要写这篇文章。本来 官方文档 已经写得足够好了,而且还有翻译好了的中文文档(原链接已经失效,这里就不放链接了)。

不过最后想想还是写吧,一方面是觉得这个话题比较新也比较有趣,另一方面是想尝试另一种风格去写一篇技术博客。

我知道TDD好,可我不想TDD

前两天我作为讲师给公司新同事介绍TDD,给大家展示了一个完整的TDD流程,大致内容跟 袁慎建 在B站发布的 TDD教学视频 一致,以实现一个计算0到50的斐波那契数列为例,介绍完整的TDD流程。

演示完了大家似乎都挺有兴致,后面有一个练习作业,用TDD的方式去开发“FizzBuzz”的功能。看了一圈,也都有模有样完成得不错。

可到了后面写一个实际的项目代码的时候,有不少同事问:“这个环节我可以不用TDD吗?我想先写实现,后面有时间再把测试用例补上”。

我问,为什么不用呢?

“太麻烦,而且项目时间有点紧。”

是的,虽然理论上来说,使用TDD是可以提升开发效率的,并且可以有效地降低BUG存在的风险。但对于刚接触TDD的人来说,先写测试是一件非常麻烦且痛苦的事情。

就如《重构》里说的那样:

我发现,编写优良的测试程序,可以极大提高我的编程速度,即使不进行重构也一样如此。这让我很吃惊,也违反许多程序员的直觉,所以我又必要解释一下这个现象。

那究竟是什么会让“程序员的直觉”认为TDD会降低编程速度呢?

前两天看到熊杰大佬的一篇文章: 为什么别人写代码那么快? ,其实可以感受到,我们用TDD也可以很快!文章里面提到几个点,其中有一个就是 使用了参数化测试用例

参数化的测试用例不仅让测试目的看起来更清晰,代码量也减少很多。也许你一开始没有用这种方式组织测试,那么这就应该是一个重构的目标——测试也需要重构,大多数时候是针对重复代码。

所以,今天我想介绍一下JUnit 5 的参数化测试。

第一个参数化测试

假如你写了一个“判断字符串是否是回文字符串”的程序,使用TDD的方式去开发,你可能需要写一个这样的测试用例:

@Test void shouldReturnTrue_GivenPalindromeString() { assertTrue(isPalindrome("racecar")); assertTrue(isPalindrome("radar")); assertTrue(isPalindrome("able was I ere I saw elba"));

而使用了JUnit 5 的参数化测试,它大概长这样:

@ParameterizedTest @ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" }) void palindromes(String candidate) { assertTrue(isPalindrome(candidate));

你只需要把@Test注解换成@ParameterizedTest,然后给这个测试方法一个“参数源”(后面会介绍什么是参数源)就行了,然后这个方法就会被循环调用。

乍一看好像并没有少太多代码。别急,这只是第一个入门案例,JUnit 5 还提供了许多其它的方式来做参数化测试。

@ValueSource

上面的案例中使用了@ValueSource,是最简单的来源之一。它支持所有基本类型再加上StringClass类型的字面值。比如:

@ValueSource(ints = { 1, 2, 3 })

@EnumSource

@EnumSource能够很方便地提供Enum常量。该注解提供了一个可选的names参数,你可以用它来指定使用哪些常量。如果省略了,就意味着所有的常量将被使用,就像下面的例子所示。

@ParameterizedTest @EnumSource(TimeUnit.class) void testWithEnumSource(TimeUnit timeUnit) { assertNotNull(timeUnit); @ParameterizedTest @EnumSource(value = TimeUnit.class, names = { "DAYS", "HOURS" }) void testWithEnumSourceInclude(TimeUnit timeUnit) { assertTrue(EnumSet.of(TimeUnit.DAYS, TimeUnit.HOURS).contains(timeUnit));

@MethodSource

这个注解可以指定测试类或外部类中的一个或多个工厂方法。这个工厂方法应该返回一个Stream或者能够转成Stream的,比如IntSteamCollectionIteratorIterable,数组等。这个Stream的泛型默认应该是Arguments类型。但如果你要测试的方法只有一个参数,那也可以指定这个参数的类型作为泛型。

@ParameterizedTest @MethodSource("stringProvider") void testWithSimpleMethodSource(String argument) { assertNotNull(argument); static Stream<String> stringProvider() { return Stream.of("foo", "bar");

默认情况下,如果不传入方法名,它会自动去找与要测试的方法相同名字的方法。

如果要测试的方法的参数不止一个,那就需要返回一个Arguments类型的Stream了。

@ParameterizedTest @MethodSource("stringIntAndListProvider") void testWithMultiArgMethodSource(String str, int num, List<String> list) { assertEquals(3, str.length()); assertTrue(num >=1 && num <=2); assertEquals(2, list.size()); static Stream<Arguments> stringIntAndListProvider() { return Stream.of( Arguments.of("foo", 1, Arrays.asList("a", "b")), Arguments.of("bar", 2, Arrays.asList("x", "y"))

@CsvSource

这是一个很强大的注解,熊杰分享的文章的视频里面,用的就是这个注解,它可以传入多个字符串数组,然后JUnit会默认根据方法签名的参数类型去转换字符串,支持转换层所有基本类型、枚举、File类、时间日期类型、货币类型、Class类型等。比如对于LocalDate类型,它的转换规则是这样的:

"2017-03-14" → LocalDate.of(2017, 3, 14)

更完整的案例:

@ParameterizedTest @CsvSource({ "foo, 1", "bar, 2", "'baz, qux', 3" }) void testWithCsvSource(String first, int second) { assertNotNull(first); assertNotEquals(0, second);

@CsvFileSource

跟上面的@CsvSource类似,不过你可以指定一个文件作为输入来源。我觉得实际工作中应该用得不太多,如果case多到需要用文件的话,在单元测试层面来说,确实是一个不小的负担,并且可读性也不好,所以其实不是很推荐使用。

@ParameterizedTest @CsvFileSource(resources = "/two-column.csv") void testWithCsvFileSource(String first, int second) { assertNotNull(first); assertNotEquals(0, second); // two-column.csv的内容: foo, 1 bar, 2 "baz, qux", 3

@ArgumentsSource

可以用来指定一个自定义且能够复用的ArgumentsProvider

@ParameterizedTest @ArgumentsSource(MyArgumentsProvider.class) void testWithArgumentsSource(String argument) { assertNotNull(argument); static class MyArgumentsProvider implements ArgumentsProvider { @Override public Stream<? extends Arguments> provideArguments(ExtensionContext context) { return Stream.of("foo", "bar").map(Arguments::of);

前面介绍了很多“输入源”,但你可能会发现,很多输入源都只支持比较基本的类型,但实际工作中,我们更多的输入可能是一个对象。

这个时候,我们可以使用参数聚合的功能,代码是这样:

@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")) { assertEquals(Gender.F, person.getGender()); else { assertEquals(Gender.M, person.getGender()); assertEquals("Doe", person.getLastName()); assertEquals(1990, person.getDateOfBirth().getYear());

但是这样使用arguments.getxxx的可读性太差了!单元测试有“活文档”之称,不应该这样写。

这个时候,我们可以实现一个自定义的聚合器。

要使用自定义聚合器,只需实现ArgumentsAggregator接口并通过@AggregateWith注释将其注册到@ParameterizedTest方法的兼容参数中。当调用参数化测试时,聚合结果将作为相应参数的参数提供。

@ParameterizedTest @CsvSource({ "Jane, Doe, F, 1990-05-20", "John, Doe, M, 1990-10-22" void testWithArgumentsAggregator(@AggregateWith(PersonAggregator.class) Person person) { // perform assertions against person 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));

编程技巧:如果你发现自己在代码库中为多个参数化测试方法重复声明@AggregateWith(XXX.class),此时你可能希望创建一个自定义组合注解,比如@CsvToMyType,它使用@AggregateWith(XXX.class)进行元注解。以下示例通过自定义@CsvToPerson注解演示了这一点。