iT邦幫忙

2021 iThome 鐵人賽

DAY 24
2
Software Development

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

Day 24「小步快跑」Service 與單元測試(上)

筆者前陣子蠻喜歡路跑的,但跑了很久,成績卻一直沒有明顯進步,為此感到因擾。後來有一天,一位朋友跟我說,我的步頻太慢,導致跑步過程無意識做了太多不必要的動作,因此一跑長,表現就會下降。後來我上網找了一個 180 bpm 的歌單,練了幾次後發現原本腳步笨重的問題不見了,速度也有所提升,後來參加幾次,成績真的有明顯進步,真的太神奇了!


180 bpm

在做完 Controller 後,Service 應該有的接口樣貌應該已有了很大的確定性。在 Clean Architecture 的分層裡, Service 所在的 Use Case 層,就是為了 Controller 層而存在的。試想,你提供的接口,對方肯定是用得很順利,就算有什麼不好用的接口,也早就改掉了不是嗎?不然,Controller 怎麼做完它的工作的?

於是,Service 接下來要做的事,就只剩下:「想辦法依 Controller 給的資料,完成自己該的的事,並在需要時通知 Controller。」

一開始,就像做 Controller 時一樣,我們也來照需求分析 這個「申請獎學金」的 Service 工作。在 Clean Architecture 的規畫中,Service 的工作就是個「自動化流程」的執行者,這個 ApplyScholarshipService 的流程就是:「控管流程,根據申請書的資料,向檔案管理員索取資料,依規定審核後填寫正式記錄。」

為了列出測項,我們可以先條列出 Service 要做的事依序有哪些:

  1. 調閱學生資料
  2. 調閱獎學金規定的資料
  3. 查驗是否符合資格
  4. 填寫正式申請書
  5. 存檔

於是乎,測項就可以條列如下:

測項 行為
找不到學生資料 Exception 987
Repository 取得學生資料時發生錯誤 Exception 666
找不到獎學金資料 Exception 369
Repository 取得獎學金資料時發生錯誤 Exception 666
資格不符 Exception 375
Repository 儲存資料時發生錯誤 Exception 666
成功 void

同樣地,在這裡列的測項只是個計畫,不是個嚴格的規定,等會做到一半,發現有需要時,還是可以調整。

測項一:完全 ok

有了上一篇寫 Controller 的經驗,我們這下寫 Service 的單元測試應該是駕輕就熟。先來試寫個測試,一樣,醜沒關係,先要有測到東西:

@Test
void all_ok() throws DataAccessErrorException, ClientSideErrorException {


    StudentRepository studentRepository = Mockito.mock(StudentRepository.class);

    ApplyScholarshipService applyScholarshipService
            = new ApplyScholarshipService(studentRepository);

    ApplicationForm applicationForm
            = new ApplicationForm(12345L, 98765L);

    applyScholarshipService.apply(applicationForm);

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

    // 調閱學生資料
    // 調閱獎學金規定的資料
    // 查驗是否符合資格
    // 填寫正式申請書
    // 存檔

}

各位可以看到,這裡程式啥都沒做,測試就過了。意料中事,因為我們一開始就希望把這個方法設計成「沒有出錯,就不要通知我」的樣子。

這裡我覺得測試有點醜,但我想要再忍一下,我想等下一個測項做完再來重構,這樣會比較知道哪些地方是重複的。

測項二:查學生時「找不到資料」

至此,各位有覺得奇怪嗎?

為什麼這裡要先做「全部 OK」的測項?

這的確是與 Controller 的實踐方式不同。會這麼做的原因,除了為各位展示 TDD 的測項順序你可以自己決定以外,也是想要演示一下「先從需要做少點事的測項開始」是什麼樣子。

TDD 做多了以後,你會對測項的安排有個感覺,於是你會抓到屬於自己的安排節奏,這不一定適合別人,但,老話一句,程式是你在寫,只要你自己舒服就好。

現在我們要來進行第二個測項了。這裡我稍微看了一下,先做其他哪個測項好像工作量都沒差太多,於是我決定按時間順序來。先加測試:

@Test
void when_student_not_exist_then_987() {

    StudentRepository studentRepository = Mockito.mock(StudentRepository.class);
    Mockito.when(studentRepository.find(12345L))
            .thenReturn(Optional.empty());


    ApplyScholarshipService applyScholarshipService
            = new ApplyScholarshipService(studentRepository);

    ApplicationForm applicationForm
            = new ApplicationForm(12345L, 98765L);

    ClientSideErrorException actualException = Assertions.assertThrows(ClientSideErrorException.class,
            () -> applyScholarshipService.apply(applicationForm));

    Assertions.assertEquals(987, actualException.getCode());

}
    

我們先假設學生 12345 並不存在,於是在拿學生資料時,Repository 給了我們一個「empty」。這裡我們用了一個 Optional 的回傳值,主要就是想表達,每一層的實作都應該把它的依賴隱藏起來,只透露出自己跟上層講好的「行為」。同時被呼叫者的介面,應該要由呼叫者來決定,要以「對方」好用為優先。

Oh,這時我已經有點煩躁了,這個測試竟然有 20 行。為了早點重構它,我們趕快來寫程式吧:

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

    // 調閱學生資料
    studentRepository.find(applicationForm.getStudentId())
            .orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));

    // 調閱獎學金規定的資料
    // 查驗是否符合資格
    // 填寫正式申請書
    // 存檔

}

這裡用了 orElseThrow 的語法,使得 service 可以比較方便用比較少的程式碼,達到「找不到學生時,丟 Exception」的目的。這也就是我們一直強調的,「讓使用者決定介面」。

好了,我等不及了,我們快來重構吧。這兩個測試「細節」太多了,我只想在測試的第一層看到「抽象業務邏輯」,好消息是我們現在有了足夠多的重複程式碼,方便我們決定該抽什麼出去。於是我決定同時重構這兩個測試:


@Test
void all_ok() throws DataAccessErrorException, ClientSideErrorException {

    given_student_exists(12345L);

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

}

@Test
void when_student_not_exist_then_987() {

    given_student_NOT_exists(12345L);

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

    then_error_code_is(987);

}


抽出去的 private methods 礙於篇幅,就沒有放上來了。讀者可在文末附上的 GitHub Repository 中找到細節。

測項三:Repository 取得學生資料時發生錯誤

有時候取資料有誤不是因為資料不存在,而是資料來源有問題,或是系統與資料的連線有問題。無論如何,我們都得處理。這裡的處理,採用「攔下來轉拋」的策略。先寫測試:

    @Test
    void when_DB_fail_on_getting_student_then_666() throws RepositoryAccessDataFailException {

        studentRepository = Mockito.mock(StudentRepository.class);
        Mockito.when(studentRepository.find(12345L))
                .thenThrow(new RepositoryAccessDataFailException());

        applyScholarshipService = new ApplyScholarshipService(studentRepository);

        dataAccessErrorException = Assertions.assertThrows(DataAccessErrorException.class,
                () -> applyScholarshipService.apply(application_form(12345L, 98765L)));

        Assertions.assertEquals(666, dataAccessErrorException.getCode());

    }

很醜,沒關係,我們很快會回來重構,現在先把紅燈轉綠再說:

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

        // 調閱學生資料
        Student result;
        try {
            result = studentRepository.find(applicationForm.getStudentId())
                    .orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));
        } catch (RepositoryAccessDataFailException e) {
            throw new ServerSideErrorException("failed to retrieve student data", 666);
        }
       

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

    }

快來重構吧!這段醜死了!

首先這個 DataAccessErrorException 的名字我突然不喜歡了。它與另一個 ClientSideErrorException 的名字沒有對比性,這會使 Controller 寫不漂亮。同時,這個測試也不像其他測試一樣只曝露抽象邏輯,這裡我也想改掉:

@Test
void when_DB_fail_on_getting_student_then_666() throws RepositoryAccessDataFailException {

    assume_repository_would_fail_on_getting_student(12345L);

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

    then_server_side_error_code_should_be(666);

}

呼,舒爽多了,可以繼續了。

測項四:找不到獎學金資料

我們還需要獎學金的資料,才能進行下一步動作,這時如果獎學金不存在,那就麻煩了,這代表客戶端送了不對的數值來,Controller 必須回報,而 Service 則負責丟出夾帶足夠資訊的 Exception。

首先,用測試描述此情況:

@Test
void when_scholarship_not_exist_then_369() {

    ScholarshipRepository scholarshipRepository = Mockito.mock(ScholarshipRepository.class);
    Mockito.when(scholarshipRepository.findOptional(98765L))
            .thenReturn(Optional.empty());

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

    then_client_side_error_code_is(369);

}

我們去跟 Repository 要 98765 這個獎學金的資料,如果找不到,我們期待它回一個 empty 給我們,這樣我們就可以用 Java 8 的 fluent API 來處理了,這裡跟前一段 Student 的案例相仿。我們透過測試來確保這個介面會好用,而不是下一個工程師使用時的咒罵。

來寫程式吧:

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

    // 調閱學生資料
    try {
        studentRepository.find(applicationForm.getStudentId())
                .orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));
    } catch (RepositoryAccessDataFailException e) {
        throw new ServerSideErrorException("failed to retrieve student data", 666);
    }

    // 調閱獎學金規定的資料
    scholarshipRepository.findOptional(applicationForm.getScholarshipId())
            .orElseThrow(() -> new ClientSideErrorException("cannot find scholarship", 369));


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

}

測試有個「抽象程度不同」的問題,重構之:

@Test
void when_scholarship_not_exist_then_369() throws RepositoryAccessDataFailException {

    given_student_exists(12345L);
    
    given_scholarship_NOT_exists(98765L);

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

    then_client_side_error_code_is(369);

}

測項五:Repository 取得獎學金資料時發生錯誤

好,我想我們找到一些 pattern 了,這會使我們後面的事情「有跡可循」。但無論如何,我們還是一步一步來,先寫測試吧:

    @Test
    void when_DB_fail_on_getting_scholarship_then_666() throws RepositoryAccessDataFailException {

        given_student_exists(12345L);

        Mockito.when(scholarshipRepository.findOptional(98765L))
                .thenThrow(new RepositoryAccessDataFailException());

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

        then_server_side_error_code_should_be(666);

    }

可以看出,雖然找學生沒問題,但找獎學金時卻出了問題。此時我們應該要預期 Service 的行為是「攔下來轉拋」,並給出 666 這個 error code,而且 Exception 必須是 Server Side Error 的類型,這樣 Controller 才有足夠訊息判斷該做什麼事。

有了測試,就來寫程式吧:

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

    // 調閱學生資料
    try {
        studentRepository.find(applicationForm.getStudentId())
                .orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));
    } catch (RepositoryAccessDataFailException e) {
        throw new ServerSideErrorException("failed to retrieve student data", 666);
    }

    // 調閱獎學金規定的資料
    try {
        scholarshipRepository.findOptional(applicationForm.getScholarshipId())
                .orElseThrow(() -> new ClientSideErrorException("cannot find scholarship", 369));
    } catch (RepositoryAccessDataFailException e) {
        throw new ServerSideErrorException("failed to retrieve scholarship data", 666);
    }


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

}

這裡我們一樣可以透過「抽取方法」,來使測試的抽象程度一致,像這樣:

@Test
void when_DB_fail_on_getting_scholarship_then_666() throws RepositoryAccessDataFailException {

    given_student_exists(12345L);

    assume_DB_would_fail_on_getting_scholarship_data(98765L);

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

    then_server_side_error_code_should_be(666);

}

除此之外,主程式也有些可以整理的地方。這裡在同一個方法裡有兩個 try-catch clause,這一來使得方法一下子變很長,二來也使讀者要一口氣消化過多的「實作細節」,才能理解其代表的「抽象邏輯」。我們來透過抽取方法的手段,來代替讀者消化這些細節吧:

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

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

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


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

這樣,細節就藏起來了,程式又回到「短短的」的樣子了。

暫停,休息一下

眼尖的朋友可以發現,我還蠻常用函式來隱藏細節的。沒錯,這其實是模仿「重構與模式」書中,作者 Joshua Kerievsky 常用的一個模式:「複合函式(Composed Method)」。在一個方法中,如果我們希望把細節隱藏起來,而只表現出抽像邏輯,就可以考慮使用這個模式。

到目前為止,我們透過 Repository 把資料取出來了。而且感謝 Repository 的幫忙,Service 完全不需要管 Database 的樣貌,它甚至不在乎資料是否是存在 Database 中。有了清楚的分層,根據 Uncle Bob 的說法,Service 可以更專心處理它的份內工作:自動化流程。

然而,我們事情還沒做完。接下來 Service 這位「承辦專員」要做的事還有:

  1. 驗證申請資格
  2. 填寫正式申請書
  3. 保存正式申請書

不過,礙於篇幅,我們先到這邊,下回再來繼續完成後面的事情。

謎之聲:「Contoller 好聊難測,Service 好測難聊。」

Reference

  1. Joshua Kerievsky, Refactoring to Patterns, Addison-Wesley Professional, 2004
tags: ithelp2021

上一篇
Day 23 「啟動!Outside-In 之路」Controller 與單元測試
下一篇
Day 25 「行禮如儀?行將就木?」Service 與單元測試(下)
系列文
你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)30

1 則留言

0
longyue0521
iT邦新手 5 級 ‧ 2021-09-25 09:50:29

加油,還有六天!

我要留言

立即登入留言