效能問題往往隨著使用者規模而浮現,多執行緒(Multi-threading)是常見的解法之一。但併發一旦進入受測範圍,單元測試的寫法就需要調整。這篇介紹兩種筆者實務上常用的做法。

為什麼多執行緒測試難寫#

延續教務處網站的情境。獎學金申請結束後,無論結果如何都要寄發 Email 通知申請者。Email 含有大量 I/O 操作,逐封發送太慢,因此採用多執行緒併發發送。

問題是,單元測試(Unit Test)的三個步驟(準備資料、執行、檢查結果)原本是有序進行的:得等執行完才能驗證。一旦把任務丟到 Thread 上跑,控制權就交給機器,測試端怎麼知道該等多久?

「等久一點就好」也不是答案。等 3 秒夠嗎?等 3 分鐘呢?任務排程的時機機器並未保證,而且如果原本 30 毫秒就能完成的事情被硬拖到 3 秒,測試一多就跑得超久。Kent Beck(測試驅動開發提出者)說過:「Programmer tests should be fast.」測試一旦太慢,會打斷思緒,大家也會懶得跑。所以「固定秒數的等待」並不是好方法。

競態條件(Race Condition)會讓固定 sleep 變得既不可靠又浪費時間。要驗證併發行為,得想辦法讓測試在「任務真的完成」這個事件發生時自動往下走。

方法一:透過 Future 接住結果#

如果任務本身會回傳值,就有機會利用 Future 把結果接起來。例如發 Email 後回傳 boolean 表示成功或失敗,呼叫端(包括測試)可以用 Future 去查詢結果。

服務端程式:

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.get() 強迫等待任務完成,再驗證結果:

@Test
void when_send_returns_future() throws ExecutionException, InterruptedException {

    Mailer mailer = Mockito.mock(Mailer.class);
    SendResultEmailService service = new SendResultEmailService(mailer);

    when(mailer.send(any(ScholarshipResult.class)))
            .thenReturn(true, true, false);

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

    int goods = 0;
    int bads = 0;
    for (Future<Boolean> future : futures) {
        if (future.get()) {
            goods++;
        } else {
            bads++;
        }
    }
    assertEquals(2, goods);
    assertEquals(1, bads);

}

這裡的「等待」與固定 sleep 完全不同:是等任務完成後立刻往下走,沒有浪費時間。對比 CountDownLatch 等同步原語,這種寫法在「方法本身會回值」的情境是最簡潔的選擇。

測試也要重構#

上面的測試讀起來不算直覺,閱讀時需要從註解才能理解意圖。透過抽出輔助方法,可以讓測項只剩下商業意圖:

@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);

}

被隱藏起來的細節仍然存在,只是搬到輔助方法裡。這樣的好處是讀測試的人能先看到「在做什麼」,再決定要不要往下追「怎麼做」。

重構的最佳時機就是測試剛通過時,這時對程式的記憶最深刻。等過了幾天再回頭重構,光是看懂自己當初寫了什麼就要花一番工夫。

方法二:分離任務與排程,分別驗測#

如果任務本身不回傳值,就無法用 Future 來接結果。這時改變策略:把多執行緒的功能拆成兩個層次

  • 任務本身的行為
  • 將任務排程的行為(在 Java 中常透過 ExecutorService.submit

針對這兩個層次分別測試:先單獨驗證任務的行為是否正確;再把任務做成假物件,單獨驗證「排程」的動作是否如預期發生(例如 submit 被呼叫了幾次、傳入什麼任務)。

這種寫法本質就是上一篇談到的行為測試(Behavior Verification)。它建立在我們對依賴的信任之上:相信任務本身的行為已被獨立驗證過,那麼上層只要確保「有依需求發送任務」即可。

易測就會易用#

多執行緒的程式要好測,結構也要配合。如果「任務」與「排程」實作在同一個方法裡,就會像不分職責的 calculate() 一樣難以拆解。其實這個原則對任何程式都成立,多執行緒只是讓問題更快浮現。

書中常被引用的觀點是「讓測試成為程式的第一個使用者」。先測,自然就會先發現難用之處——因為易測,就會易用。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10261345