文章

AssertJ

之前就一直看到很多开源框架使用AssertJ做测试,一直想看看,也没来得及。最近又好好看了看近期接触的优秀开源框架的测试用例,AssertJ再度映入眼帘。简单试了一下,果然比JUnit爽多了。不能忍了,今天终于抽空好好看了看AssertJ的架构。

  1. 断言谁
  2. 架构
  3. 一些优雅的用法
    1. 断言描述: as(String description, Object… args)
    2. 自定义错误信息:withFailMessage()
    3. 对数组/Iterable的断言
      1. 内容校验
      2. 返回iterable校验
      3. 元素属性校验
      4. 抽取特定值
      5. 过滤元素
      6. 映射元素
    4. 异常断言
  4. equal
  5. 全局设置
  6. assertj-examples
  7. fluent api
  8. 迁移脚本
  9. 感想

assertj只是一个比较方便的断言工具,集成于测试框架JUnit/TestNG中。后者则是一整套测试框架。

断言谁

  • https://assertj.github.io/doc/#assertj-overview

core能断言jdk里的各种类型。还有拓展包比如guava module能断言guava里定义的一些基本类型。

架构

为啥一个Assertions.assertThat()几乎能断言所有东西?因为它是无数个方法的重载方法……

Assertions类里,有无数个接受不同参数的assertThat方法。

比如assertThat(Double),返回的实际是DoubleAssert。可以从它看一下assertj的架构:

  • DoubleAssert
    • AbstractDoubleAssert
      • AbstractComparableAssert
        • AbstractObjectAssert
          • AbstractAssert
            • Assert接口

Assert接口是最核心的接口,定义了所有可用的断言方式,比如isEqualTo/isIn/isNotNull等。

AbstractAssert是这些实现的base class,实现了所有common断言。关于如何在父类里统一实现能返回子类型的泛型方法,参考用泛型实现父子类的fluent api

AbstractObjectAssert则是断言Object类的所有base class。后面各级别断言类的实现则完全符合他们要断言的对象的类继承体系,很好理解。

assertThat()只返回一个断言对象,比如参数为double时,就返回一个DoubleAssert。断言对象里有很多可以用来下判断的方法。

assertThat()这个方法本身只返回一个断言对象,没有做出任何断言动作,所以不存在断言失败或断言成功。因此,assertThat(actual.equals(expected))是错误的,属于只用过Junit的人对AssertJ的误用,相当于给assertThat()传了一个boolean参数,实际上会返回一个BooleanAssert断言对象,但没有任何断言动作。

断言方法返回的并不是boolean,因为返回boolean是无意义的,我们并不接收断言方法的返回值。断言方法是通过抛出异常的方式来下判断的,如果测试代码不抛出异常,能够正常运行,说明断言都是正确的。

比如assertThat(actual).isEqualTo(expected)isEqualTo这儿基础的方法在AbstractAssert基类里:

1
2
3
4
5
  @Override
  public SELF isEqualTo(Object expected) {
    objects.assertEqual(info, actual, expected);
    return myself;
  }

它(断言方法)其实就是在发现值不符合预期后,抛出异常AssertionError

1
2
3
4
  public void assertEqual(AssertionInfo info, Object actual, Object expected) {
    if (!areEqual(actual, expected))
      throw failures.failure(info, shouldBeEqual(actual, expected, comparisonStrategy, info.representation()));
  }

AssertionError是jdk自带的异常类,继承Error,继承Throwable

所以断言方法并不是要返回boolean,而是要在不符合预期时抛出异常!

同时也可以看到isEqualTo返回的是断言对象本身,所以还可以继续用fluent api写多次断言:

1
2
3
assertThat(actual).isNotNull()
    .isEqualTo(expected)
    .startsWith(xxx)

一些优雅的用法

基本用法就如同它的写法一样,理解起来非常流畅:

  • https://assertj.github.io/doc/#use-assertions-class-entry-point

有几个比较强的用法,能解决直接用JUnit断言时不方便的地方:

断言描述: as(String description, Object… args)

  • https://assertj.github.io/doc/#assertj-core-assertion-description

给断言加上描述,一旦报错能立刻定位到出问题的地方,也能清楚知道不匹配的点:

1
2
3
4
5
TolkienCharacter frodo = new TolkienCharacter("Frodo", 33, Race.HOBBIT);

// failing assertion, remember to call as() before the assertion!
assertThat(frodo.getAge()).as("check %s's age", frodo.getName())
                          .isEqualTo(100);

报错:

1
[check Frodo's age] expected:<100> but was:<33>

之前用junit断言,都要看一下代码上下文或者代码注释才能知道在测什么、怎么错的。现在直接看报错信息就差不多了。

但是as一定要写在断言前面,因为一旦断言报错(抛出异常)后面的就不执行了,as里的描述就不会输出了

自定义错误信息:withFailMessage()

  • https://assertj.github.io/doc/#assertj-core-overriding-error-message

如果报错,可能输出一些自定义的错误信息。比如断言对象的实际完整内容:

1
2
3
4
5
TolkienCharacter frodo = new TolkienCharacter("Frodo", 33, Race.HOBBIT);
TolkienCharacter sam = new TolkienCharacter("Sam", 38, Race.HOBBIT);
// failing assertion, remember to call withFailMessage/overridingErrorMessage before the assertion!
assertThat(frodo.getAge()).withFailMessage("should be %s", frodo)
                          .isEqualTo(sam);

输出:

1
java.lang.AssertionError: should be TolkienCharacter [name=Frodo, age=33, race=HOBBIT]

之前在junit如果想断言一个json或一个太复杂的对象,不输出一下结果不是很确定要断言的值,此时都要先System.out.println看一下,再删掉重新写断言。现在可以直接在断言出错的时候把整个对象的值都以自定义message的方式打出来,很方便!

对数组/Iterable的断言

  • https://assertj.github.io/doc/#assertj-core-group-assertions

assertj最亮眼的功能!每一项都值得好好看看。

数组指array,对应的类是AbstractObjectArrayAssert;Iterable对应的类是AbstractIterableAssert

内容校验

可以校验列表包含的值、包含的值的顺序、值出现的次数等。

1
2
3
4
    assertThat(importResult.existedKols)
            .size().isEqualTo(2)
            .returnToIterable()
            .containsExactlyInAnyOrder(2L, 5L);

containsExactlyInAnyOrder能够在判断所有元素(exactly)的同时做到位置无关。

返回iterable校验

上面例子的returnToIterable能够做到在判断完size之后,继续回到iterable做其他校验,从而能够写连续的校验语句。

怎么做到从size返回到iterable的?其实实现也很简单,assertj的AbstractIterableSizeAssert实现的时候,里面并非只有size信息,还有原始的AbstractIterableAssert对象信息,所以就能轻松会到iterable assertion状态:

1
2
3
4
5
  @Override
  @CheckReturnValue
  public AbstractIterableAssert<IterableAssert<T>, Iterable<? extends T>, T, ObjectAssert<T>> returnToIterable() {
    return source;
  }

如果只保存了size信息,自然是不可能返回到iterable的。

类似双向链表,想倒回去,就必须比单链表保存更多的信息。

元素属性校验

断言all/any元素的某个属性,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
List<TolkienCharacter> hobbits = list(frodo, sam, pippin);

// all elements must satisfy the given assertions
assertThat(hobbits).allSatisfy(character -> {
  assertThat(character.getRace()).isEqualTo(HOBBIT);
  assertThat(character.getName()).isNotEqualTo("Sauron");
});

// at least one element must satisfy the given assertions
assertThat(hobbits).anySatisfy(character -> {
  assertThat(character.getRace()).isEqualTo(HOBBIT);
  assertThat(character.getName()).isEqualTo("Sam");
});

// no element must satisfy the given assertions
assertThat(hobbits).noneSatisfy(character -> assertThat(character.getRace()).isEqualTo(ELF));

抽取特定值

first/last/element(index),然后再对该值进行校验。

注意:after navigating you can only use object assertions unless you have specified an Assert class or preferrably an InstanceOfAssertFactory

比如:

1
2
3
assertThat(hobbitsName).element(1, as(STRING))
                       .startsWith("sa")
                       .endsWith("am");

或者:

1
2
3
4
// alternative for strongly typed assertions
assertThat(hobbitsName, StringAssert.class).first()
                                           .startsWith("fro")
                                           .endsWith("do");

否则用不了string assert。

jdk21终于也加入first()方法了……

过滤元素

很像lambda的filter,但是有一个强大的地方:支持直接使用级联名称过滤嵌套对象的属性!而且不会NPE!

Filter supports nested properties/fields. Note that if an intermediate value is null the whole nested property/field is considered to be null, for example reading “address.street.name” will return null if “address.street” is null.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Predicate
assertThat(fellowshipOfTheRing).filteredOn( character -> character.getName().contains("o") )
                               .containsOnly(aragorn, frodo, legolas, boromir);

// function
assertThat(fellowshipOfTheRing).filteredOn(TolkienCharacter::getRace, HOBBIT)
                               .containsOnly(sam, frodo, pippin, merry);

// filters use introspection to get property/field values
assertThat(fellowshipOfTheRing).filteredOn("race", HOBBIT)
                               .containsOnly(sam, frodo, pippin, merry);

// nested properties are supported
assertThat(fellowshipOfTheRing).filteredOn("race.name", "Man")
                               .containsOnly(aragorn, boromir);

映射元素

很像lambda的map,但是lambda extract写起来依然有点儿复杂,要先stream再map最后collect

1
2
3
4
// extract the names ...
List<String> names = fellowshipOfTheRing.stream().map(TolkienCharacter::getName).collect(toList());
// ... and finally assert something
assertThat(names).contains("Boromir", "Gandalf", "Frodo", "Legolas");

assertj的extracting api则非常强大:

  1. 抽取单元素
  2. 抽取多元素为tuple
  3. 抽取并flatten

同时依然像filter一样支持嵌套元素!

单元素映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// "name" needs to be either a property or a field of the TolkienCharacter class
assertThat(fellowshipOfTheRing).extracting("name")
                               .contains("Boromir", "Gandalf", "Frodo", "Legolas")
                               .doesNotContain("Sauron", "Elrond");

// specifying nested field/property is supported
assertThat(fellowshipOfTheRing).extracting("race.name")
                               .contains("Man", "Maia", "Hobbit", "Elf");

// same thing with a lambda which is type safe and refactoring friendly:
assertThat(fellowshipOfTheRing).extracting(TolkienCharacter::getName)
                               .contains("Boromir", "Gandalf", "Frodo", "Legolas");

// same thing map an alias of extracting:
assertThat(fellowshipOfTheRing).map(TolkienCharacter::getName)
                               .contains("Boromir", "Gandalf", "Frodo", "Legolas");

抽取多field为tuple

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// when checking several properties/fields you have to use tuples:
import static org.assertj.core.api.Assertions.tuple;

// extracting name, age and and race.name nested property
assertThat(fellowshipOfTheRing).extracting("name", "age", "race.name")
                               .contains(tuple("Boromir", 37, "Man"),
                                         tuple("Sam", 38, "Hobbit"),
                                         tuple("Legolas", 1000, "Elf"));

// same assertion with functions for type safety:
assertThat(fellowshipOfTheRing).extracting(TolkienCharacter::getName,
                                            tolkienCharacter -> tolkienCharacter.age,
                                            tolkienCharacter -> tolkienCharacter.getRace().getName())
                                .contains(tuple("Boromir", 37, "Man"),
                                          tuple("Sam", 38, "Hobbit"),
                                          tuple("Legolas", 1000, "Elf"));

The extracted name, age and race’s name values of the current element are grouped in a tuple, thus you need to use tuples for specifying the expected values.

extract and flatten则和flatmap类似:

Let’s assume we have a Player class with a teamMates property returning a List<Player> and we want to assert that it returns the expected players:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Player jordan = ... // initialized with Pippen and Kukoc team mates
Player magic = ... // initialized with Jabbar and Worthy team mates
List<Player> reallyGoodPlayers = list(jordan, magic);

// check all team mates by specifying the teamMates property (Player has a getTeamMates() method):
assertThat(reallyGoodPlayers).flatExtracting("teamMates")
                             .contains(pippen, kukoc, jabbar, worthy);

// alternatively, you can use a Function for type safety:
assertThat(reallyGoodPlayers).flatExtracting(BasketBallPlayer::getTeamMates)
                             .contains(pippen, kukoc, jabbar, worthy);

// flatMap is an alias of flatExtracting:
assertThat(reallyGoodPlayers).flatMap(BasketBallPlayer::getTeamMates)
                             .contains(pippen, kukoc, jabbar, worthy);

// if you use extracting instead of flatExtracting the result would be a list of list of players so the assertion becomes:
assertThat(reallyGoodPlayers).extracting("teamMates")
                             .contains(list(pippen, kukoc), list(jabbar, worthy));

异常断言

这个看看文档就行,主要断言exception的信息、cause、root cause等:

  • https://assertj.github.io/doc/#assertj-core-exception-assertions

equal

默认的isEqualTo使用的是Object#toString,但是也可以不使用toString,而仅仅比较field

  • https://assertj.github.io/doc/#assertj-core-recursive-comparison

这个方法在3.12.x里叫usingRecursiveComparison(),以取代isEqualToComparingFieldByFieldRecursively。因为前者能提供fluent api:

1
2
3
4
5
6
// assertion succeeds as the data of both objects are the same.
assertThat(sherlock).usingRecursiveComparison()
                    .isEqualTo(sherlock2);

// assertion fails as Person equals only compares references.
assertThat(sherlock).isEqualTo(sherlock2);

field比较规则:先取test object的field,再从expected object里找对应的field

  • https://assertj.github.io/doc/#how-field-values-are-resolved

还可以指定忽略掉一些field:https://assertj.github.io/doc/#assertj-core-recursive-comparison-ignoring-fields

1
2
3
4
// assertion succeeds as name and home.address.street fields are ignored in the comparison
assertThat(sherlock).usingRecursiveComparison()
                    .ignoringFields("name", "home.address.street")
                    .isEqualTo(moriarty);

全局设置

  • https://assertj.github.io/doc/#assertj-core-configuration

assertj-examples

assertj的示例:

  • https://github.com/assertj/assertj-examples

src/testsrc/main多 :D

fluent api

用泛型实现父子类的fluent api

迁移脚本

从junit的断言一键迁移到assertj:https://assertj.github.io/doc/#assertj-migration

感想

果然,当有摩托车骑的时候,谁还愿意吭哧吭哧蹬自行车呢?有了AssertJ,我是再也不愿意用JUnit assertion了。只有不断学习,才能掌握更先进的工具,越来越轻松,诚不我欺啊!

感觉最近“偷功”比较多:D 从spring-data-elasticsearch等一众开源框架上学到了testcontainers、AssertJ等非常优秀的测试工具。是时候把JUnit还有之前gap了很久的springboot test续起来了~

本文由作者按照 CC BY 4.0 进行授权