棒球揮棒可以選擇 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