iT邦幫忙

2021 iThome 鐵人賽

DAY 8
2
Software Development

你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)系列 第 8

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

上一篇
Day 07 「Tell. Don't Ask.」 測試與依賴:測行為
下一篇
Day 09 「世事難預料」單元測試與例外處理
系列文
你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)30

尚未有邦友留言

立即登入留言