iT邦幫忙

2021 iThome 鐵人賽

DAY 23
0
Software Development

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

Day 23 「啟動!Outside-In 之路」Controller 與單元測試

台灣的職業運動中,最具代表性的應該就是棒球了。大家有去打擊練習場玩過嗎?現在的打擊練習場,在業者持續改良轉型下,已經慢慢轉變成大人小孩都適點的綜合型娛樂場所了。而說到棒球的打擊,筆者也是略懂略懂,可以依個人喜好,選擇進行 Inside-Out,或是 Outside-In 的攻擊。

Outside-In 是一種強力拉回型的攻擊方式,打者在球進到攻擊範圍內,算準揮棒的甜蜜點,在球棒加整到最快的瞬間擊中球,將球送出。一般而言,力量較強的大砲型打者,比較喜歡這種方式。而 Inside-Out 不同,它強調把身體重心留在原地,蓄勢待發,把球看清楚,等到攻擊時機成熟,再一口氣釋放力量,把球送出。由於 Inside-Out 的攻擊方式會把球看久一點,所以很多時候會把球送到打擊方向的相反方向,形成球評主播口中常說的「反方向攻擊」。常見於安打型球員。若論國內 Inside-Out 打擊最具代表性球員,則非中信兄弟看板球星「火星恰」彭政閔莫屬了。


Inside-out 教科書 - 彭政閔,圖片截自 WikiPedia

如同棒球,寫程式也可以 Inside-Out,也可以 Outside-In,依場景不同,開發者可以自由選擇。

上一篇中我們分析了一個「申請獎學金」的實際案例,而從今天開始一連四篇,我們要來試看看 Outside-In 配合 Clean Architecture 的開發要怎麼進行。我們會從後端 API 服務中,位於 Interface Adatper 層的 Controller 開始,一層一層地往內把這個 Query 的程式與測試做出來。因為我們已經聊過 TDD 了,所以接下來每一層的程式,我們都會以 TDD 的方式進行。

測項

筆者在以 TDD 進行開發時,習慣先把可能會遇到的場景先列出來。這樣做的好處,除了先幫助自己先釐清接下來要做的事情以外,也可以在開發的路上,有個可以參照的對象,比較不會不小心走歪路。

測項 結果
學生不存在 400
獎學金不存在 400
資料存取錯誤 500
其他異常 500
成功 200

請留意,這些測項是一開始我們對這個類別行為的規劃,而不是規定。因此,隨著待會開發的進行,我們會在需要的時候隨時對其進行修改。

測項一:學生不存在

這是一個 Controller,其任務就是要「轉化」前端來的申請單,找到合適的 service 並發請求,最後把結果(或錯誤)回給前端。

第一步,我們來寫一個會 Fail 的測項,來確保待會兒綠燈時,這個 Controller「學生不存在」時的反應是如預期的,所以我們要跟 Dependency Injection 的 Service 串通好,待會有人來申請獎學金時,要丟出指定 Exception。

@Test
void student_NOT_exists() throws Exception {

    ApplicationForm applicationForm = new ApplicationForm(
            9527L,
            55688L
    );

    ApplyScholarshipService applyScholarshipService = Mockito.mock(ApplyScholarshipService.class);
    Mockito.doThrow(new StudentNotExistException("ANY_MESSAGE"))
            .when(applyScholarshipService)
            .apply(applicationForm);

    MockHttpServletRequestBuilder request = MockMvcRequestBuilders
            .post("/scholarship/apply")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(applicationForm));

    mockMvc.perform(request)
                .andExpect(status().is(400))
                .andExpect(content().json(objectMapper.writeValueAsString(ApiResponse.bad(987))));



}

我知道很醜。醜沒關係,我們待會會重構。現在先跑一下,確保它會 Fail:

好,沒問題,我們什麼 code 都沒寫,連路徑都還沒指定,會 404 很正常。這就代表這個測項有測到東西。

喔對了,這個測項還有另一個很重要的任務:「定路徑」,這個測項一旦通過,這個功能在系統中的 url 也就定了,所以,第一個測項還蠻責任重大的,不建議同時再背負其他太複雜的邏輯。

寫程式,亮綠燈

那就來寫程式吧!這裡我不會像 Kent Beck 在 Test Driven Development 書中那樣一步跨那麼小,因為篇幅有限,我會直接把邏輯寫出來。在其實工作中,一步要多大,各位可以自己決定,舒服就好。

@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {

    return ResponseEntity.status(400).body(ApiResponse.bad(987));

}

各位可以看到我不管三七二十一,見面就丟 400 出去,還附一個先前介紹過,我們與前端講好的 error code:987。這是故意的,因為我就是要少做一點事,等新測項把真正的判斷邏輯「逼」出來,我才要來做,才不會浪費時間。

重構

是的,測試太醜了。來重構吧!我先用 Extract Method 的方法,把操作細節隱藏起來,暴露抽象意圖:

    @Test
    void student_NOT_exists() throws Exception {

        assume_student_not_exist(9527L);

        mockMvc.perform(request(
                        "/scholarship/apply"
                        , application_form(9527L, 55688L)))
                .andExpect(status().is(400))
                .andExpect(content().json(bad_response_content(987)));

    }

各位不妨假裝自己不懂程式,就光是念出這個測項中的「英文單字」,應該就能推敲出這個測項在測什麼場景。如果可以,代表我們這個重構效果不錯。

測項二:獎學金不存在

如果學生存在,但對方要申請的「獎學金」根本不存在,那我們也肯定能使這個申請成立。一樣,我們由測試開始。有了重構過的第一個測試,我們期待第二個測試會好寫一點:

@Test
void scholarship_NOT_exists() throws Exception {

    Mockito.doThrow(new ScholarshipNotExistException("ANY_MESSAGE"))
            .when(applyScholarshipService)
            .apply(application_form(9527L, 55688L));

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(400))
            .andExpect(content().json(bad_response_content(369)));// 369: scholar not exists

}

運行結果應該要是錯的,而且錯得跟我們預期的一樣,想要 369,結果卻得到 987:

這個測項,我們做的事很少,只是 copy-paste 一下前一個測項,確保我們有測到東西。其實可以預期以後應該也不會多太多,這就是時常小幅重構的好處。

總之,現在我們可以來寫程式了。

寫程式,亮綠燈

此時,剛剛故意不寫,拿來判斷 Exception 種類的邏輯就被新的測項給「逼」出來了:

@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
    try {
        applyScholarshipService.apply(applicationForm);
    } catch (StudentNotExistException e) {
        return ResponseEntity.status(400).body(ApiResponse.bad(987));
    } catch (ScholarshipNotExistException e) {
        return ResponseEntity.status(400).body(ApiResponse.bad(369));
    }
    
    return null;
}

這裡我們抓了兩種 Exception,按照與前端的協議,一種給 369,一種給 987,這下不管是哪一個資料有誤的 case,未來一旦被改錯,我們也不怕了。

至於方法最後的 return null,因為我們還沒測到那,就先留著吧!

重構

一樣,測試太醜了,同一個方法的兩行邏輯的「抽象程度」不同。這在 Uncle Bob 的 Clean Code 有特別提到,是會干擾閱讀的。我們不喜歡,所以改掉它:

@Test
void scholarship_NOT_exists() throws Exception {

    assume_scholarship_not_exists(55688L);

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(400))
            .andExpect(content().json(bad_response_content(369)));// 369: scholar not exists

}

測項三:資料存取錯誤

有時候我們會遇到資料庫一些不可抗力的因素,譬如資料庫突然故障,機房網路瞬斷等等,這時也要給前端一個適當的通知,讓它知道是後端出了問題,不是使用者的問題。這會有助於前端顯示正確的訊息,或適度的隱藏。

先寫一個測試描述場景:

@Test
void data_access_error() throws Exception {

    Mockito.doThrow(new DataAccessErrorException("ANY_MESSAGE"))
            .when(applyScholarshipService)
            .apply(application_form(9527L, 55688L));

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(500))
            .andExpect(content().json(bad_response_content(666)));// 666: data access error

}

跑測試,確定會錯,而且錯在我們預期的地方:

寫程式,亮綠燈

看來,我們越來越熟練了呢!那就直接加邏輯吧:

    @PostMapping("/scholarship/apply")
    public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
        try {
            applyScholarshipService.apply(applicationForm);
        } catch (StudentNotExistException e) {
            return ResponseEntity.status(400).body(ApiResponse.bad(987));
        } catch (ScholarshipNotExistException e) {
            return ResponseEntity.status(400).body(ApiResponse.bad(369));
        } catch (DataAccessErrorException e) {
            return ResponseEntity.status(500).body(ApiResponse.bad(666));
        }
        return null;
    }

有沒有發現,我們現在在做的事越來越小步?因為結構已經慢慢穩定下來,現在只要不加太大新功能,這個 Controller 差不多就長這樣了。

重構

重構測試,讓測試更具表現力:

@Test
void data_access_error() throws Exception {

    assume_data_access_would_fail(9527L, 55688L);

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(500))
            .andExpect(content().json(bad_response_content(666)));// 666: data access error

}

再重構

在進入下一步前,我想整理另一塊地方。我們剛剛建了三個 Exception,這使得 service 的簽名變得非常長:

public void apply(ApplicationForm applicationForm) throws StudentNotExistException, ScholarshipNotExistException, DataAccessErrorException {
    // To be implemented...
}

我們不喜歡太長的簽名,於是想來重構一下,讓他簡單一點。在 Teddy Chen 的「例外處理的逆襲」書中,有教我們一些「重構例外」的方法,各位有興趣可以看看,這裡,我們試著用其中一個手段來整理。

這裡我們發現,「學生不存在」與「獎學金不存在」都是「用戶的問題」,而目前為止對此種錯誤的處理都大同小異,只有回覆的代碼不同,所以我想要來把這兩個錯誤合併,並以「錯誤代碼」來細分就好:

  @PostMapping("/scholarship/apply")
    public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
        try {
            applyScholarshipService.apply(applicationForm);
        } catch (ClientSideErrorException e) {
            return ResponseEntity.status(400).body(ApiResponse.bad(e.getCode()));
        } catch (DataAccessErrorException e) {
            return ResponseEntity.status(500).body(ApiResponse.bad(666));
        }
        return null;
    

這裡我們可以看到,兩個跟「使用者出錯」有關的 Exception 已經合併,service 的簽名也可以縮短,而前端照樣可以由 code 來區分錯誤的細節。至於 server 端的錯誤,目前還沒有長太大,所以就暫時這樣了,等未來真有困擾再說也行。

測項四:未知錯誤

天有不測風雲,寫程式哪有永遠對的。在運行途中,service 難保不會出什麼預料之外的亂子。而因為我們這裡對已知可能發生的問題都已用了 Checked Exception 來接,還可能發生問題的話,肯定只剩代表 bug 的 Runtime Exception 了。

我們來寫個測項吧:

@Test
void unknown_error() throws Exception {

    given_some_bug_exists(9527L, 55688L);

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(500))
            .andExpect(content().json(bad_response_content(999)));

}

    

來跑看看:

果真錯了。

這裡我刻意一下子就「又寫又抽方法」,我只是想表現:各位在進行時,可以自己決定自己步伐要多大,不一定要照別人或我的方式,只要你自己覺得舒服自在,且能短時間內頻繁綠燈就好。

寫程式,亮綠燈

來吧,我們來讓我們的測試也亮綠燈:

@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
    try {
        applyScholarshipService.apply(applicationForm);
    } catch (ClientSideErrorException e) {
        return ResponseEntity.status(400).body(ApiResponse.bad(e.getCode()));
    } catch (DataAccessErrorException e) {
        return ResponseEntity.status(500).body(ApiResponse.bad(666));
    } catch (Exception e) {
        return ResponseEntity.status(500).body(ApiResponse.bad(999));
    }
    return null;
}

重構

看看,好像沒什麼特別糟糕的味道,那就跳過不重構吧!

測項五:完全正確 => 測試,程式,重構

終於,來到最後一個測項了。這時,要的做事與前面大同小異,我想應該可以不用細談中間過程了吧!

總之,當所有事都正確,理應回傳 200 給前端,而且內容可以是空的:

@Test
void all_ok() throws Exception {

    mockMvc.perform(request(
                    "/scholarship/apply"
                    , application_form(9527L, 55688L)))
            .andExpect(status().is(200))
            .andExpect(content().json(objectMapper.writeValueAsString(ApiResponse.empty())));

}


此時程式也完成:

@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
    try {
        applyScholarshipService.apply(applicationForm);
    } catch (ClientSideErrorException e) {
        return ResponseEntity.status(400).body(ApiResponse.bad(e.getCode()));
    } catch (DataAccessErrorException e) {
        return ResponseEntity.status(500).body(ApiResponse.bad(666));
    } catch (Exception e) {
        return ResponseEntity.status(500).body(ApiResponse.bad(999));
    }

    return ResponseEntity.status(200).body(ApiResponse.empty());

}

跑一下測試,全綠燈,且測試時間非常短:

非常划算,可以多跑幾次 XD

這時可以再檢查一下,如果有什麼看不順眼的就重構,沒有就大功告成囉!

我個人是對現在「Service 會知道與 Client 溝通的 error code」這件事比較感冒啦,如果是平常工作時,我會再針對這一點修改一下,不過,這還是看你自己習慣。正如我一再強調的:「沒有標準答案,你舒服就好。」

結論

我們花了不短的篇幅,演示了怎麼用 TDD 的方式,一步步建構後端 API 服務的門面:Controller。

Controller 位於 Clean Architecture 的 Interface Adapter 層,直接與最外層的 Framework 與 Internet 相接。如果我們要把這一層寫得非常乾淨,與 Framework 完全隔絕,那會非常麻煩,付出的成本也許會超出收益。

再者,現在的 Framework 功能都很強大,例如我們專案中使用的 Spring Boot,妥善地利用,它可以為我們解決很多與外面世界溝通的事情,譬如 url 路徑與 Http 方法的 Mapping。但於此同時,測試的獨立性也就被犧性了,因為這會使程式很難與框架分開測試。

幸好,也正因為現在的框架都很厲害,它們大多都提供了很方便的測試套件,因此,在 Controller 的測試,我們可以直接使用框架提供的套件來幫助我們測試,本篇中的測試就屬於此類。

方便歸方便,再往內的 Service 層就不建議如此了。越是核心的元件,就越要遠離框架,才能保持我們的核心邏輯的自由度,不會被「細節」給綁架了。畢竟,解決商業上問題的是「核心邏輯」,不是框架等細節。

謎之聲:「框架是細節,網路是細節,Web 是細節,Dababase 也是細節。」

Reference

  1. GitHub Repository:https://github.com/bearhsu2/ithelp2021.git
  2. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship. Upper Saddle River, NJ: Prentice Hall, 2009.
  3. Kent Beck, Test Driven Development : By Example, Addison-Wesley Signature Series, 2002
tags: ithelp2021

上一篇
Day 22 「戲如人生」以真實案例分析 Clean Architecture 的分層原則
下一篇
Day 24「小步快跑」Service 與單元測試(上)
系列文
你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰 (Java 篇)30

尚未有邦友留言

立即登入留言