效能問題往往隨著使用者規模而浮現,多執行緒(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