iT邦幫忙

2021 iThome 鐵人賽

DAY 25
1
Software Development

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

Day 25 「行禮如儀?行將就木?」Service 與單元測試(下)

筆者寫作年資不算長,但寫到後來,還是多多少少能在動筆之前,感受一些主題的容易度,譬如理論的主題,對我來說比較好寫,跟程式比較相關的主題就比較沒那麼簡單。倒也不是說邏輯很難,而是在已經用程式表達了一次意圖後,又要用文字再闡述一次,而且不能只是把程式翻成中文重講一遍,是要延伸一些深入的論述,這件事其實(至少對我來說)沒那麼容易。

但沒關係,寫程式跟寫作一樣,本來就是要不斷練習,要能夠「行禮如儀」地寫出足夠量的文章,又不能只是不斷複製貼上自己的文章(或程式碼),不然就變成「行將就木」了。

日本作家樺澤紫苑的「AZ原則」,要我們以輸出為目的輸入,以達到更好的學習成效,我想就是支持我一直寫作的動力之一吧!

我們來繼續 Service 未完成的工作吧。

在取得 Student 與 Scholarship 的資料以後,這位「獎學金申請」專員,接下來要做的事,就是要真正去驗證此申請是否合乎規定,如果是,那就要為申請者填寫一份正式的申請書,並回頭請「檔案管理員」把這份申請書保存起來,工作才算完成。

我們來看看剩下的測項有哪些:

測項 行為
超過申請時間 Exception 374
資格不符 Exception 375
Repository 儲存資料時發生錯誤 Exception 666

會長大的待辦清單

等等,「超過申請時間」何時蹦出來的?一開始列測項時沒說有這回事呀!難不成這待辦清單還會長大?

是的。它本來就會長大。

在列測項時,我們的確是照著我們當時的最佳理解來進行的,但是人對同一件事的理解,隨著你的接觸越多,是會越來越完整的,這點 Kent Beck 在 TDD 一書也有提到。當我們對這個功能的需求了解多了,自然會知道一些原本沒有料到的問題。而測試代表需求,當需求成長,那測項也會長大,這是自然而然的事。

所以,此為自然現象,請安心服用。

測項六:超過申請時間

我們來看看,如果超過申請時間,會發生什麼事情。首先先用測試來描述場景與需求:

@Test
void when_overtime_then_374() throws RepositoryAccessDataFailException {

    given_student_exists(12345L);
    given_scholarship_exists(98765L, scholarship(2021, 7, 31));
    given_today_is(2021, 8, 1);

    when_apply_and_fail_on_server_side(application_form(12345L, 98765L));

    then_client_side_error_code_is(374);

}

這個測項也很簡單,學生與獎學金都存在,獎學金的 deadline 是 7/31,但今天已經是 8/1 了,於是預期 service 會用 374 這個 error code 來表示「申請逾期」。

各位可以看到我這裡步伐跨很大,直接把 Composed Method 寫出來了,而不是用重構的。我故意的,我不是任性,我只是想要不厭其煩地再強調一次:「步伐要小一點是通則,但實際要多小,沒有嚴格規定,你自己多練習,自己覺得舒服就好。」

寫完測試來寫程式吧:

    public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {

        // 調閱學生資料
        Student student = findStudent(applicationForm);

        // 調閱獎學金規定的資料
        Scholarship scholarship = findScholarship(applicationForm);

        // 查驗是否符合資格
        LocalDate deadline = scholarship.getDeadline();
        LocalDate now = LocalDate.now();
        if (now.isAfter(deadline)) {
            throw new ClientSideErrorException("application over time", 374);
        }

        // 查驗是否符合資格
        // 填寫正式申請書
        // 存檔

    }

來重構吧。這裡的程式有抽象程度不對等的問題,操作細節與商業邏輯被擺在一起了。這個可以靠 Extract Method 的方法解決:

public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {

    // 調閱學生資料
    Student student = findStudent(applicationForm);

    // 調閱獎學金規定的資料
    Scholarship scholarship = findScholarship(applicationForm);

    // 查驗是否符合資格
    checkDeadline(scholarship);

    // 查驗是否符合資格
    // 填寫正式申請書
    // 存檔

}

又回到「Composed Method」的樣貌了。

暫停,移除重複功能

可能已經有人發現了,這裡的 CheckDeadline,與先前的某一段示範 Mock Static 的程式碼很像。是的,因為我用了同一個場景來舉例,但你應該也同時發現了,這兩個例子的實作方法並不相同。其實,在真實工作上,也常有機會遇到這種情形。

遇到類似情形,有人會選擇實施「複製貼上大法」,這沒什麼不好,但別忘了,重複乃萬惡之淵藪,一旦測試通過了,還是要趕快重構來消重複才行。尤其這裡根本不是類似,而是同一場景,其實更是要重複利用程式碼才行,不過這邊主要是為了要示範,所以筆者就故意兩邊都留下,方便讀者對照。

真實生活遇到了的話,我是會建議各位做完新功能後,直接移除舊功能,或是抽共用,因為重複了,而我們不喜歡重複。

測項七:資格不符

邏輯再往下走,我們來看看資格不符時,會發生什麼事。我們假設這個獎學金的規定是「只有博士班」能申請,如此一來,大學生跑來申請肯定不行吧!我們來看看怎麼處理這樣的邏輯。

一樣先寫測試:

private static final LocalDate july31 = LocalDate.of(2021, 7, 31);
  
@Test
void when_disqualified_then_375() throws RepositoryAccessDataFailException {

    Mockito.when(studentRepository.find(12345L))
            .thenReturn(Optional.of(new Student("Michael", "Jordan", "Bachelor")));

    given_scholarship_exists(98765L, scholarship(july31));

    given_today_is(july31);

    when_apply_with_form_and_client_side_error_happens(application_form(12345L, 98765L));

    then_client_side_error_code_is(375);

}

這裡我們假設今天是 7/31,那申請截止日同為 7/31 的獎學金理應沒問題,但我們假設這個獎學金只有「博士生」能申請,那麼很明顯就資格不符了。依照我們先前講好的規範,資格不符是 Client 端的問題,並且要提示 375 的 error code。

來寫個 code 使測試通過吧:

public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {

    // 調閱學生資料
    Student student = findStudent(applicationForm);

    // 調閱獎學金規定的資料
    Scholarship scholarship = findScholarship(applicationForm);

    // 查驗是否符合資格
    checkDeadline(scholarship);

    // 查驗是否符合資格
    if (!student.getDegree().equals("PhD")) {
        throw new ClientSideErrorException("this scholarship is for PhD students only", 375);
    }
    // 填寫正式申請書
    // 存檔

}

重構測試與程式。我們一樣不想曝露太多細節:

@Test
void when_disqualified_then_375() throws RepositoryAccessDataFailException {

    given_student_exists(12345L);

    given_scholarship_exists(98765L, scholarship(july31));

    given_today_is(july31);

    when_apply_with_form_and_client_side_error_happens(application_form(12345L, 98765L));

    then_client_side_error_code_is(375);

}
public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {

    // 調閱學生資料
    Student student = findStudent(applicationForm);

    // 調閱獎學金規定的資料
    Scholarship scholarship = findScholarship(applicationForm);

    // 查驗是否符合資格
    checkDeadline(scholarship);

    // 查驗是否符合資格
    checkProgramIsPhD(student);
    // 填寫正式申請書
    // 存檔

}

這裡其實有個蠻明顯的壞味道:Feature Envy。我們晚點會重構掉。為什麼不現在做?因為這會跟下一篇要講的 Entity 有關,於是這裡就先不動了。

測項八:寫入申請單失敗

Service 的最後一項工作,就是寫一份正式的申請書,並且請檔案管理員代為歸檔。這時如果檔案管理員處理有誤,Service 的責任就是轉包為 Controller 看得懂的錯誤,並且往外發出去。先寫測試:

@Test
void when_DB_fail_on_writing_application_to_DB_then_666() throws RepositoryAccessDataFailException {


    given_student_exists(12345L, "PhD");

    given_scholarship_exists(98765L, scholarship());

    given_today_is(july31);

    Mockito.doThrow(new RepositoryAccessDataFailException())
            .when(applicationRepository).create(any(Application.class));

    when_apply_and_fail_on_server_side(application_form(12345L, 98765L));

    then_server_side_error_code_should_be(666);

}

程式:

    public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {

        // 調閱學生資料
        Student student = findStudent(applicationForm);

        // 調閱獎學金規定的資料
        Scholarship scholarship = findScholarship(applicationForm);

        // 查驗是否符合資格
        checkDeadline(scholarship);

        // 查驗是否符合資格
        checkProgramIsPhD(student);
        // 填寫正式申請書
        Application application = applicationForm.toApplication();


        // 存檔
        try {
            this.applicationRepository.create(application);
        } catch (RepositoryAccessDataFailException e) {
            throw new ServerSideErrorException("failed to create application", 666);
        }

    }

很好,我們越來越熟練了。接著重構程式:

public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {

    // 調閱學生資料
    Student student = findStudent(applicationForm);

    // 調閱獎學金規定的資料
    Scholarship scholarship = findScholarship(applicationForm);

    // 查驗是否符合資格
    checkDeadline(scholarship);

    // 查驗是否符合資格
    checkProgramIsPhD(student);

    // 填寫正式申請書
    Application application = applicationForm.toApplication();

    // 存檔
    createApplication(application);

}

重構測試:

@Test
void when_DB_fail_on_writing_application_to_DB_then_666() throws RepositoryAccessDataFailException {

    given_student_exists(12345L, "PhD");

    given_scholarship_exists(98765L, scholarship());

    given_today_is(july31);

    assume_DB_would_fail_on_creating_application_data();

    when_apply_and_fail_on_server_side(application_form(12345L, 98765L));

    then_server_side_error_code_should_be(666);

}


圖片截自網路

...等等!思考題來了!

這裡我們發現了一個新的問題:當此人重複申請的時候,應該怎麼處理?我們一開始的確是沒想到,不過既然現在發現了,那還是得處理。

當然,我們可以先問 Repository 此申請是否已經存在,但是,在這個微服務當道的年代,可能同時有幾百台機器也在線服務著,前一刻詢問時還不存在的資料,有可能幾百毫秒後要寫入時就被另一台機器搶先寫入了。

這個問題其實不難,也不容易,因為能影響決策的因素太多了,譬如儲存裝置是否是 RDB、是本機去存還是委託其他機器存、資料的設計本身是否就有可供分辨的欄位、要 Consistent 還是要 Eventually Consistent 就好…等等,所以,應該要綜合考慮真實世界的完整 Context 才行。

這裡我將會故意留一個思考題給各位,邀請各位來想想,在怎樣的 Context 下,你會怎麼處理這個問題。

結論

我們用了兩篇文章來描述在 Clean Architecture 中,位於 Use Case 層的 Service 如何進行它「自動化」的工作,並且我們使用了 TDD 的方式來一步步建構出完整邏輯,最後,我們也留了一個思考題,邀請讀者思考一個與「實際 Context」有高度相關,但其實各位在工作中應該蠻常會遇到的問題。

至此,我們還剩下 Repository 與 Entity 還沒完成。我們將在下一篇裡接著討論這兩個角色的實作準則。

下回見!

Reference

  1. 樺澤紫苑,最高學以致用法:讓學習發揮最大成果的輸出大全,春天出版集團,2020
  2. Kent Beck, Test Driven Development : By Example, Addison-Wesley Signature Series, 2002
  3. 「搞笑談軟工」聊 Composed Method:http://teddy-chen-tw.blogspot.com/2012/05/implementation-patterns-composed-method.html
tags: ithelp2021

上一篇
Day 24「小步快跑」Service 與單元測試(上)
下一篇
Day 26 「一個巨星的誕生」Entity、Repository 與單元測試
系列文
你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)30

尚未有邦友留言

立即登入留言