棒球揮棒可以選擇 Outside-In(由外而內)或 Inside-Out(由內而外)兩種策略,寫程式也能依場景挑選不同方向。本章開始連續四章,使用 Outside-In 搭配 Clean Architecture(簡潔架構)的方式,從 Interface Adapter 層的 Controller 進入,一路向內把申請獎學金的功能完成。每一層都會以 TDD 方式進行。

測項清單#

進入 TDD 之前,先把可能會遇到的場景列成清單,邊實作邊修正:

情境HTTP 狀態碼
學生不存在400
獎學金不存在400
資料存取錯誤500
其他異常500
成功200

測項只是一開始的規劃,不是規定。隨著實作進行,可以隨時新增、合併或調整。

測項一:學生不存在#

Controller 的責任是把前端的申請單轉換給 Service 處理,再把結果或錯誤回給前端。先寫一個會 Fail 的測試,驗證「學生不存在」時是否回 400。

@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))));

}

第一個測試也順便決定了 URL 路徑,因此盡量避免在第一個測項裡塞太多複雜邏輯。

寫程式讓它變綠:

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

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

}

刻意「不管三七二十一就回 400」,先把判斷邏輯留給後續測項逼出來。

接著用 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)));

}

新測項把 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;
}

測項三:資料存取錯誤#

當資料庫故障或網路瞬斷時,Controller 應該回 500 並附上錯誤碼,協助前端決定要顯示或隱藏訊息:

@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)));

}

重構:合併同類錯誤#

Service 同時宣告三個 Checked Exception 會讓簽名過長。觀察「學生不存在」與「獎學金不存在」其實都屬於客戶端錯誤,差別只在 error code,可以合併成一個用代碼區分的例外:

@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;
}

測項四:未知錯誤#

對 RuntimeException 做兜底處理,避免任何 bug 導致前端收到不可預期的回應:

@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());

}

結論#

Controller 位於 Clean Architecture 的 Interface Adapter 層,緊鄰最外層的 Framework 與 Internet。要把它寫到完全與框架隔絕成本太高,但 Spring Boot 等現代框架都提供完整的測試支援,因此這層直接借助框架的測試套件是務實選擇。然而越往內到 Service 就應該避免如此,畢竟解決商業問題的是核心邏輯而不是框架。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10270363