Day 08 「说好的射後不理呢?」多线程环境下的单元测试

今天来聊聊「多线程」的单元测试。

多线程测试的困难点

当系统成长到一个程度,效能的重要性就会慢慢浮现,随着使用者数量越来越大,「效能」的影响也会变大,最终变成系统的瓶颈。如果放任不管,用户体验就会变差,甚至有可能影响程序的正确性,造成巨大的损失。「多线程」(Multi-threaded)的设计,正是解决效能问题的方法之一。

举例来说,在我们的「教务处网站」上,同学可以申请奖学金。而最终,无论结果如何,我们都得发个 Email 通知申请者对吧?然而我们知道,Email 的寄发牵扯到蛮大比重的 I/O 操作,不是一件很快的事,如果一封一封发,那执行时间就太久了。於是我们想用 Multi-threaded 通知的方式来解决这个问题。

问题来了,回头看我们先前提到的单元测试三步骤:准备资料、执行、检查结果,这三件事是有时序性的,我不等到执行完毕,我其实没办法测,因为结果还没出来啊。

「我可以等。」请问,你要等多久?任务一旦丢给 Thread 去跑,後面的事情就是机器在做,等於是离开我们掌控范围了,你怎麽知道要等到什麽时候?

「那就等久一点啊。」久一点是多久?3 秒够不够?3 分钟够吗?首先,如上所述,我已经把任务丢给机器去做了,机器根本就没有跟我保证他什麽时候要排到我的工作。就算你只等 3 秒吧,问题这本来是个 30 msec 能做完的事,硬被你拖到 3 秒,这事儿一旦发生多了,你的测试就会跑很久。

「跑很久有什麽问题?」Kent Beck 説过:「Programmer tests should be fast.」,程序开发者写的测试必须要快,跑得慢的测试,一来打断思绪,二来大家嫌麻烦就不想测了。那你写了一个测试结果没人要跑,不是很浪费时间吗?所以,「固定时间的等待」不是一个很好的测试方法。

「那就不要测好了,请 QA 帮我们测?」你,站起来,出去!XD

截图自 Youtube

言归正传,测还是得测的。
坊间对此问题有蛮多解法,有些还是蛮直觉的,以下介绍两个笔者自己实务上比较常用的方法。

方法一:回传 Future 的任务

有些方法是会回传值的。这种会稍微比较好测一点。我们可以拿个 Future 去接。譬如举个最简单例子,如果发 Email 後会回传 boolean,告诉我成功或失败,这时呼叫者可以利用 Future 的接口,来查看任务的回传值。这个呼叫者当然也可以是个 Unit Test 的测项,这时我们就可以在测项里验证结果了。

我们先来看看这样的程序该怎麽写:

public class SendResultEmailService {

    private final Mailer mailer;
    private final ExecutorService executorService = Executors.newFixedThreadPool(300);

    // ... 中略
    
    public List<Future<Boolean>> send(List<ScholarshipResult> results) {

        List<Future<Boolean>> futures = new ArrayList<>();

        for (ScholarshipResult result : results) {
            futures.add(executorService.submit(() -> mailer.send(result)));
        }

        return futures;

    }
}

因为方法会回 Future,所以我们大可以在测试里把 Future 打开来看回传值是否符合预期,如下:

@Test
void when_send_returns_future() throws ExecutionException, InterruptedException {

    // 准备假 Mailer
    Mailer mailer = Mockito.mock(Mailer.class);
    SendResultEmailService service = new SendResultEmailService(mailer);

    // 假 Mailer 会回传两个 true,一个 false
    when(mailer.send(any(ScholarshipResult.class)))
            .thenReturn(true, true, false);

    // 跑起来
    List<Future<Boolean>> futures = service.send(
            Arrays.asList(
                    new ScholarshipResult(),
                    new ScholarshipResult(),
                    new ScholarshipResult()
            ));

    // 检查 Future 里 true 与 false 的个数
    int goods = 0;
    int bads = 0;
    for (Future<Boolean> future : futures) {
        if (future.get()) {
            goods++;
        } else {
            bads++;
        }
    }
    assertEquals(2, goods);
    assertEquals(1, bads);

}

我们利用了 Future 的 get 介面,强迫测试等所有任务都执行完再来检查结果。但这里的「等待」,跟先前说的等待不同,如果你的等待,是等一个固定秒数,那就会遇到等待时间很难抓,或是等太久浪费时间的问题。这里则是等任务完成後自动往下走,所以时间不会浪费。

测试也要重构

By the way,各位有没有发现,上述的测试如果不看注解,还是得花一番工服才能理解?其实我也这麽认为。这种测试正确有余,表现力却不足,如果能重构一下就更好了:

@Test
void when_send_returns_future_refactor_the_test() throws ExecutionException, InterruptedException {

    assume_mailer_execution_result_would_be(true, true, false);

    when_send_with_results(3);

    then_counts_in_futures_will_be(true, 2);
    then_counts_in_futures_will_be(false, 1);

}

这里用了一些手法,刻意地将一些实践细节隐藏起来,好让测项的第一层只剩下一些「商务逻辑」的叙述。至於藏起来的细节哪儿去了?GitHub Repository 中有详细的程序码,读者可以抓下来参考一下。如果觉得不需要知道这麽细,那其实看上面的程序码也就够了。这也是我们重构的目的。

也许你会好奇:「不是测完就好了?干嘛要重构?」要知道,测试也是程序,也会有坏味道的。而「高效程序员的 45 个习惯:敏捷开发修炼之道」一书中,作者告诉我们,重构的最佳时机,就是测试通过的时候。这时你的脑中,对刚刚写的东西印象还很深刻,这时重构效果最好。等你过三天五天再回头看这段程序,看都看不懂,还重构什麽?

所以,测试通过了,就先考虑重构,程序测试都要。

方法二:间接验测,分别验任务行为与排程行为

那麽,总有任务是不回传值的吧?那又该怎麽测?

这个问题的确普遍存在,像前一篇有讲到,如果我们设计程序时把 Command 与 Query 分开,那我们就没有办法如法泡制,透过 Future 检验回传值了。这时该怎麽办呢?

其实,山不转路转,我们还是可以测行为

一个多线程的功能,都会由两个行为组成,一个是「任务本身的行为」,一个是「将任务排程的行为」(以 Java 来说,就是 ExecuteService 的 submit 行为)。这时,我们可以将两件事情分别测试,先视你的商务逻辑,单独验测任务本身的行为,再将任务做成假物件,单独验测「排程」的行为有没有如预期发生。

至於要怎麽「验行为」,这个我们上篇聊过了,其实也就依样画葫芦而已。像这样间接验测,其实是建立於我们对依赖本身的信任。我们认为任务的行为本身是检验过的、正确的,那其实只要确保我们有依需求发送任务就好。

好测,就会好用。

当然遇到多线程的场景,还是有其他方法可以验,本篇只是介绍笔者自己较常用的两种方法。读者也许已经发现了,多线程的程序要好测,你设计的结构还是得配合才行,譬如「任务」与「排程」如果混在一起实现,测起来就会比较麻烦一点。但这点其实不管是不是多线程都一样,只是多线程的场景会让不良结构的易测性降低得更明显而已。

稍早提到的「高效程序员的 45 个习惯」,以及另一本「The Pragmatic Programmer」,两本书中都有提到另一个好的习惯,就是「让测试当你程序的第一个使用者」。这是有原因的,如同我们一再强调的,好测,就会好用。你先测看看,你才知道到时候人家用你的功能会不会好用。

谜之声:「『高效程序员的 45 个习惯』是本好书,值得一读!」

Reference

  1. Kent Beck, Programmer Test Principles:https://medium.com/@kentbeck_7670/programmer-test-principles-d01c064d7934
  2. Venkat Subramaniam, Andy Hunt: Practices of an Agile Developer: Working in the Real World, 2006
  3. Andy Hunt and Dave Thomas, The Pragmatic Programmer, Addison-Wesley Professional, 1999
tags: ithelp2021

<<:  一些类似判断是否为空的方法比较:isset, empty, is_null

>>:  Day 08:八爪章鱼之 tmux 快捷键

新新新手阅读 Angular 文件 - Day03

学习内容 这一篇的内容,是纪录阅读官方文件 tutorial: A Hero Editor 的笔记,...

前端工程师也能开发全端网页:挑战 30 天用 React 加上 Firebase 打造社群网站|Day9 发表文章页面

连续 30 天不中断每天上传一支教学影片,教你如何用 React 加上 Firebase 打造社群...

电子书阅读器上的浏览器 [Day17] 利用 Room 强化书签功能

书签功能在电脑浏览器是个很重要的功能,因为操作方便,所以通常会记录一大堆连结,并且分门别类放在不同的...

Vaadin 工具 / 後记 - day30

Vaadin Start Vaadin 官方网站提供快速产出程序码工具,所见即所得,还可设定权限,分...