使用 Awaitility 测试异步代码

对于同步方法的测试很简单,调用完后可立马检查执行状态; 而异步方法,由于我们无法确切的知道何时结束,因此以往的办法是用 Thread.sleep(500) 来预估一个执行时间。然后通常我们估计的要长于实际的时间,这就很浪费,况且偶然的超过预估的等待时间也并不意味着代码有问题。还有 sleep 方法还抛出一个检测异常 InterruptedException, 一般会要对 Thread.sleep(500) 作下简单包装。

于是今天要介绍的 Awaitility 就应运而生了,专门针对异步方法的测试。它的官方文档在 https://github.com/awaitility/awaitility/wiki/Usage。本文主要关注在 Java 8 环境下用 Lambda 的代码书写方式。Awaitlity 实际运行是以某种轮询的方式来检查是否达到某个运行状态,可设定最多,最少等待时间,或永久等待,或自定义轮询策略,之后就开始进行需要的断言,所以它可以尽可能的节省测试异步方法所需的时长。而不像 Thread.sleep(500) 一路等到黑,并且没有回头路。

通常我会在项目中给 JUnit 配上三个最佳伴侣,它们是(按 mvn dependency:tree 中的显示方式):
  1. org.awaitility:awaitility:2.0.0:test
  2. org.assertj:assertj-core: version: 3.8.0:test
  3. org.mockito:mockito-core:2.7.22:test

当然如果项目中没有异步调用自然是不需要 Awaitility, 在我的项目中是基本不可能的。以上三种都追求 DSL,以流畅的方式进行愉快的测试。

现在来尝试下 Awaitility 的几种基本的用法,先假定有下面的代码 UserService
 1package cc.unmi;
 2
 3import java.util.ArrayList;
 4import java.util.List;
 5import java.util.concurrent.ThreadLocalRandom;
 6import java.util.concurrent.TimeUnit;
 7
 8public class UserService {
 9    public final List<String> users = new ArrayList<>();
10
11    public void addUser(String username) {
12        new Thread(() -> {
13            try {
14                TimeUnit.MILLISECONDS.sleep(ThreadLocalRandom.current().nextLong(100, 500));
15            } catch (InterruptedException e) {
16            }
17            users.add(username);
18
19        }).start();
20    }
21}

我们把上面的 addUser() 方法做成了一个异步的,而且执行时间是不定的,此处设定在 100 至 500 毫秒之间,如果用常规的测试方式
1@Test
2public void couldAddUserAsync() throws Exception {
3    UserService userService = new UserService();
4    userService.addUser("Yanbin");
5
6    //Thread.sleep(600);
7    assertThat(userService.users).contains("Yanbin");
8}

显然是不行的,当然是可以在 assertThat() 前面加上 Thread.sleep(600), 但每次都会浪费平均大概 300 毫秒的时间,好像也不怎么多,但大量采用这种方式就可观了。用 Thread.sleep(600) 的方式该测试用例在 IntelliJ IDEA 中大概需要 780 毫秒, 每次至少都要 600 毫秒。

现在我们换成用 Awaitility 来进行上面的异步方法测试:

Lambda 与 AssertJ 的方式

这是我比较喜欢的断定方式
1@Test
2public void couldAddUserAsync() {
3    UserService userService = new UserService();
4    userService.addUser("Yanbin");
5
6    Awaitility.await().atMost(600, MILLISECONDS).until(() ->
7        assertThat(userService.users).contains("Yanbin"));
8}

上面是最多等 600 毫秒,until() 方法的原型是 until(Runnable supplier), 也就是直到不抛出 AssertionError 异常为止,否则测试会出现异常 ConditionTimeoutException, 并且告知条件不满足的详情。这种方式平均时间在 500 毫秒,最快时 100 多毫秒。

Awaitility 并没有提供像 Thread.sleep(600) 那样傻傻的等上 600 毫秒的机制,它总是需要一个测试条件。看到上面的代码好像是在等 600 毫秒,其实内部实现是作了一个默认每隔 100 毫秒的一个轮询,并且在指定的时间内测定条件通过后才往下走,否则是 ConditionTimeoutException 异常。所以试图写成
1await().atLeast(600, MILLISECONDS);  //并不会等待,而是立即返回
2
3assertThat(userService.users).contains("Yanbin");

想让第一行代码像 Thread.sleep(600) 一样工作是徒劳的,用 Awaitility 必须给它提供一个测试条件。

使用 Awaitility 要做的事情说到底就是两件:
  1. 如何设置轮询策略, atLeast, atMost 或默认超时为 10 秒,默认 100 毫秒的轮询间隔,或 斐波那契数列 间隔,或完全自定义; 还能永久等待(如果你愿意的话)
  2. 测定条件,直到某个方法被调用,直到数据库表中出现某行记录等等。这就是 ConditionFactory 的所有 until 方法要做的事情,见下图

了解 untilXxx(...) 方法

  • until(Callable<Boolean> conditionEvaluator):  直到 Callable 返回值为 true
  • until(Runnable supplier): 上面提到过,直到没有 AssertionError 异常为止,所以可于 Fest Assert 很好的工作
  • untilFalse(AtomicBoolean atomic), untilTrue(AtomicBoolean atomic) 用于测试 AtomicBoolean 变量是否为假,或真
  • untilCall(T ignore, Matcher<? super T> matcher) 会让代理记录下对有返回值实际的调用

这里的 Matcher 可用标准的 Hamcrest matcher, 例如 org.hamcrest.CoreMatchers 下定义了许多的 matcher, is(T value), nullValue(), containsString(String substring) 等,代码
1await().until(userDao.findByName("Yanbin"), notNullValue());
2
3await().until(userDao::findAll, hasItm("Yanbin"));  //Java 8 就是方便

带 AtomicXxx 的方法大概也是方便是 Java 8 下使用,因为匿名类或 Lambda 只能访问外部的 final 变量,如果是 final 的 AtomicXxx 值就可以操作其中的值了
1AtomicInterger atomic = new AtomicInteger(0);
2//做些操作来改变 atomic 中的值,比如 Mockito 的桩方法
3
4await().untilAtomic(atomic, equalTo(1));

untilCall 代理方法调用

演示实例,需要对前面 UserService 稍加改变,在添加完用户到 users 之后,调用一下 say(username) 方法
 1public class UserService {
 2    public final List<String> users = new ArrayList<>();
 3
 4    public void addUser(String username) {
 5        new Thread(() -> {
 6            try {
 7                TimeUnit.MILLISECONDS.sleep(ThreadLocalRandom.current().nextLong(100, 500));
 8            } catch (InterruptedException e) {
 9            }
10
11            users.add(username);
12            say(username);
13        }).start();
14    }
15
16    public String say(String username) {
17        return "Hello " + username;
18    }
19}

录制对 say(username) 方法的调用,并不是 Mock, 实际方法会被调用到
1@Test
2public void couldAddUserAsync() {
3    UserService userService = new UserService();
4    userService.addUser("Yanbin");
5
6    //直到 say("Yanbin") 被调用,并且返回值为 "Hello Yanbin", say("Yanbin") 需要精确的参数值
7    await().untilCall(to(userService).say("Yanbin"), equalTo("Hello Yanbin"));
8    assertThat(userService.users).contains("Yanbin");
9}

Awaitility 与 Mock(以 Mockito 为例)

我们需要对待测试代码再做改变,引入一个 UserDao 接口
1package cc.unmi;
2
3interface UserDao {
4    void add(String username);
5}

新的 UserService 类
 1package cc.unmi;
 2
 3import java.util.concurrent.ThreadLocalRandom;
 4import java.util.concurrent.TimeUnit;
 5
 6public class UserService {
 7    private UserDao userDao;
 8
 9    public UserService(UserDao userDao) {
10        this.userDao = userDao;
11    }
12
13    public void addUser(String username) {
14        new Thread(() -> {
15            try {
16                TimeUnit.MILLISECONDS.sleep(ThreadLocalRandom.current().nextLong(100, 500));
17            } catch (InterruptedException e) {
18            }
19
20            userDao.add(username);
21        }).start();
22    }
23}

第一种方式,试图获取捕获的参数,直到不抛出 AssertionError 异常为止
 1@RunWith(MockitoJUnitRunner.class)
 2public class UserServiceTest {
 3    @Test
 4    public void couldAddUserAsync() {
 5        UserDao userDao = Mockito.mock(UserDao.class);
 6        UserService userService = new UserService(userDao);
 7
 8        ArgumentCaptor<String> usernameCaptor = ArgumentCaptor.forClass(String.class);
 9        doNothing().when(userDao).add(usernameCaptor.capture());  //对 userDao.add(username) 方法打桩,由于无返回值,所以可用 doNothing()
10
11        userService.addUser("Yanbin");
12
13        await().until(() -> {  //until(Runnable), 直到不抛出 AssertionError 异常为止,所以需要把 MockitoException 转换为 AssertionError
14            try {
15                usernameCaptor.getValue();
16            } catch (Exception ex) {
17                throw new AssertionError(ex.getMessage());
18           }
19        });
20
21        assertThat(usernameCaptor.getValue()).isEqualTo("Yanbin");
22    }   
23} 

如果 userDao.add(username) 有返回值的话,可以用 untilCall() 方法来捕获,相关代码
1when(userDao.add(usernameCaptor.capture).thenReturn("Added Yanbin");
2await().untilCall(to(userDao).add("Yanbin"), equalTo("Added Yanbin"));
3assertThat(usernameCaptor.getValue()).isEqualTo("Yanbin");

第二种方式,使用 AtomicBoolean 来记录方法是否被调用
 1@RunWith(MockitoJUnitRunner.class)
 2public class UserServiceTest {
 3
 4    @Test
 5    public void couldAddUserAsync() {
 6        UserDao userDao = Mockito.mock(UserDao.class);
 7        UserService userService = new UserService(userDao);
 8
 9        ArgumentCaptor<String> usernameCaptor = ArgumentCaptor.forClass(String.class);
10
11        AtomicBoolean addMethodCalled = new AtomicBoolean(false);
12        doAnswer(invocationOnMock -> addMethodCalled.getAndSet(true)) //其实可以不用 usernameCaptor, 通过 invocationOnMock 也能获得参数
13            .when(userDao).add(usernameCaptor.capture());
14
15        userService.addUser("Yanbin");
16
17        await().untilTrue(addMethodCalled);
18        assertThat(usernameCaptor.getValue()).isEqualTo("Yanbin");
19    }
20}

对属性值的测定

我们自己能够以反射的方式来测定直到预期的对象内部状态,为方便起见,Awaitility 还为我们提供了几个方法来窥视属性值,下面的例子代码直接从官方 Wiki 拷过来的
1await().until(fieldIn(object).ofType(int.class). equalTo(2));
2
3await().until(fieldIn(object).ofType(int.class).andWithName("fieldName"), equalTo(2));
4
5await().until(fieldIn(object).ofType(int.class).andAnnotatedWith(MyAnnotation.class), equalTo(2));

感受默认轮询行为

如果不清楚 atMost(...).until(...) 内部做了什么,可以用 ConditionEvalutionListener 来查看,像下面的代码
1await().atMost(600, MILLISECONDS)
2    .conditionEvaluationListener(new ConditionEvaluationLogger())
3    .until(() ->
4        assertThat(userService.users).contains("Yanbin"));

从控制台的输出就能了解它与 Thread.sleep() 是要更为高效的, 而不是痴痴的等
Condition defined as a lambda expression in cc.unmi.UserServiceTest that uses cc.unmi.UserService <[]> does not contain element(s):<['Yanbin']> (elapsed time 125 milliseconds, remaining time 475 milliseconds (last poll interval was 100 milliseconds))
Condition defined as a lambda expression in cc.unmi.UserServiceTest that uses cc.unmi.UserService <[]> does not contain element(s):<['Yanbin']> (elapsed time 232 milliseconds, remaining time 368 milliseconds (last poll interval was 100 milliseconds))
Condition defined as a lambda expression in cc.unmi.UserServiceTest that uses cc.unmi.UserService <[]> does not contain element(s):<['Yanbin']> (elapsed time 337 milliseconds, remaining time 263 milliseconds (last poll interval was 100 milliseconds))
Condition defined as a lambda expression in cc.unmi.UserServiceTest that uses cc.unmi.UserService reached its end value after 440 milliseconds (remaining time 160 milliseconds, last poll interval was 100 milliseconds)
内置的轮询策略基本能满足我们的需求,所以对于如何自定义轮询不进行深入,详情请见官方 Wiki.

 


2019-07-17 补充:

Mockito 自 1.8.5 开始在  verify 时就可以用 timeout 方法,见 Verification with timeout (Since 1.8.5)。示例代码如下:
1//在 500 毫秒之内有两次对 mock.someMethod() 方法的调用
2//此处最多等待 500 毫秒
3verify(mock, timeout(500).times(2)).someMethod();

Awaitility 3.x 开始加入了 untilAsserted() 方法可与  AssertJFest Assert 进行交互,官方代码

Awaitility 3.x 或以后
1await().atMost(5, SECONDS).untilAsserted(() -> assertThat(fakeRepository.getValue()).isEqualTo(1));

Awaitility 2.x 及以下
1await().atMost(5, SECONDS).until(() -> assertThat(fakeRepository.getValue()).isEqualTo(1));

其实就是一直尝试去断言 assertThat(fakeRepository.getValue()).isEqualTo(1), 5 秒钟之内不会有一次成功就算通过测试(直到不抛出 java.lang.AssertionError 错误为止)。

参考
  1. Awaitility 官方文档
永久链接 https://yanbin.blog/test-asyn-call-with-awaitility/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。