寫程式總會遇到例外(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