寫程式總會遇到例外(Exception)。例外要怎麼處理、邏輯要怎麼驗證,是這篇要討論的主題。
「例外處理就是 try-catch 而已嗎?」是,也不是。起手式的確就是 try-catch,但同樣是 try-catch,做得好不好差距很大。Kent Beck 在《Implementation Patterns》中提到,現代軟體開發在維護與修改上的成本,遠高於最初的開發。寫出難以維護的程式碼,會很快就讓自己嚐到苦果。
例外簡介#
例外大致可以分為兩種:
- 可修復:例如網路斷線、檔案暫時讀不到。
- 不可修復:例如該有值的地方變成 null,這通常是 bug。
對應到 Java,可修復的稱為 Checked Exception,可以重試、走替代方案,或記錄下來。不可修復的稱為 Runtime Exception(屬於 Unchecked Exception),代表程式有 bug,應交由開發者排查。
只看「類型」與「可恢復性」其實還不夠決定處理方式。實務上還要追加考慮:
- Application Context:這個錯誤在系統中代表什麼意義
- Robustness Level:要做到回報就好、確保使命必達、還是介於兩者之間
- Exception Handling Policy:要重試、要回報、還是其他
例外處理本身是一門專門的學問。這裡的重點是:當開發者已經決定要採取行動,後續測試該怎麼寫。
攔下來處理#
當這一層「應該處理,也有能力處理」例外時,就用 catch 區塊在原地處理掉。例如後端 Controller 收到 Service 拋出的例外,如果不處理,前端可能會收到一個訊息含糊的 500 錯誤頁,使用者會一頭霧水。
合理做法是攔下來、轉成前後端講好的回應格式,例如以特殊代碼讓前端能精準處理。
@PostMapping("/register")
public ResponseEntity<ApiResponse> createTeam(@RequestBody RegisterRequest request) {
try {
service.execute(request);
return ResponseEntity.ok(ApiResponse.empty());
} catch (StudentNotExistException e) {
log.info("Student not found. " + e.getMessage());
return ResponseEntity.status(400).body(ApiResponse.bad(987));
}
}這段邏輯有兩條分支,對應的單元測試也是兩個:
- 一切正常時,回傳 HTTP 200
- 找不到學生時,回傳 HTTP 400,body 包含特殊代碼 987
@SpringBootTest
@AutoConfigureMockMvc
class RegisterControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private MockMvc mockMvc;
@MockBean
private RegisterService service;
@Test
void all_ok() throws Exception {
MockHttpServletRequestBuilder postRequest = MockMvcRequestBuilders
.post("/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new RegisterRequest(35L)));
mockMvc.perform(postRequest)
.andExpect(status().is(HttpStatus.OK.value()));
}
@Test
void student_not_found() throws Exception {
Mockito.doThrow(new StudentNotExistException("ANY_MESSAGE"))
.when(service)
.execute(any(RegisterRequest.class));
MockHttpServletRequestBuilder postRequest = MockMvcRequestBuilders
.post("/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new RegisterRequest(35L)));
mockMvc.perform(postRequest)
.andExpect(status().is(HttpStatus.BAD_REQUEST.value()))
.andExpect(content().json(objectMapper.writeValueAsString(ApiResponse.bad(987))));
}
}可能有人注意到「寫 log」這個動作沒被測。這是刻意省略的:log 內容稍做修改幾乎不會造成系統損失,把測試保留在「最重要的邏輯」上,可以避免過度測試帶來的脆弱性。每個技術細節都該被測,與每個技術細節都該被取捨,其實是同一件事的兩面。
攔下來轉拋#
另一種處理方式是「攔下來轉拋」。當底層拋出某個例外,但目前這一層既沒能力處理、也不該由它處理時,就用 catch 區塊把例外轉包成更貼近上層語義的型別,再丟出去。
例如 Service 收到 Repository 的 DataNotFoundException,判斷其語義為「學生不存在」,但只有 Controller 該決定回傳什麼錯誤代碼給前端,所以 Service 適合做轉拋:
public void execute(RegisterRequest request) throws StudentNotExistException {
try {
repository.register(request);
} catch (DataNotFoundException e) {
throw new StudentNotExistException("Student not exists", e);
}
}對應的測試要讓例外被丟出來並接住它,再驗證例外是否符合預期:
class RegisterServiceTest {
private final StudentRepository repository = Mockito.mock(StudentRepository.class);
@Test
void when_student_not_exists() throws DataNotFoundException {
given_student_NOT_exists(35L);
try {
create_register_service().execute(request(35L));
fail("should throw exception");
} catch (StudentNotExistException e) {
assertThat(e).hasMessageContaining("not exists");
}
}
}這裡有兩個重點:
- 訊息只驗「包含 not exists」即可,不寫死整句話。後續維護者要微調用詞時不會誤傷測試。
- 用
fail("should throw exception")強制失敗:若程式竟然能順利跑完,代表場景沒被建立起來,測試就應該紅起來。
JUnit 與 AssertJ 也提供更直接的寫法:
@Test
void when_student_not_exists_alternative() throws DataNotFoundException {
given_student_NOT_exists(35L);
StudentNotExistException actualException = Assertions.assertThrows(
StudentNotExistException.class,
() -> create_register_service().execute(request(35L))
);
assertThat(actualException).hasMessageContaining("not exists");
}除了 AssertJ,也可以搭配 Hamcrest 等 matcher 套件做更語義化的斷言。實際選哪一種,依團隊偏好即可。
為什麼要轉拋#
迪米特法則(最少知識原則)強調「只與直接認識的人交談」。Repository 並非一定要操作資料庫,它也可能呼叫 API、查 Redis、讀本機檔案。如果 Service 直接認識 SQLException,就等於跨層認識了它本不該認識的細節。
依照依賴反轉原則,Service 與 Repository 應「共同依賴一個介面」。當 Repository 的某個實作收到 SQLException 時,不應原封不動往上拋,而是轉成介面所定義的例外型別,避免上層被迫認識本不屬於它的細節。
為什麼不全用 Runtime Exception#
Uncle Bob 在 Clean Code 中建議多用 Runtime Exception。理由是:把「丟例外」與「處理例外」放在很遠的兩端時,沿途若用 Checked Exception,所有方法的簽名都得修改,違反開放封閉原則。改用 Runtime Exception 就不必沿路宣告,也滿足了「啥都不做就達到通知」的強健度等級,相當方便。
但這個做法會帶來副作用:最上層被迫去認識它本不該認識的低階例外,進而違反迪米特法則。比較乾淨的折衷做法是每遇到 Checked Exception 就「轉包或處理」:能處理就處理,否則攔下來轉成上層該認識的型別再丟。這樣同時兼顧開放封閉原則與迪米特法則。代價是每一層都要思考要轉包還是處理,寫起來較囉嗦。
沒有完美的解法。要選擇承擔哪一種副作用,視團隊與情境而定。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10261791