unit-test-exception-handler について
Spring Boot 開発者向けに、MockMvc を使って `@ControllerAdvice` と `@ExceptionHandler` をテストする方法を紹介します。HTTP ステータスや JSON 応答、フィールド単位の検証エラーを確認できます。
主な機能
- 01MockMvc による分離テスト
- 02HTTP ステータスコードの検証
- 03JSON エラーフィールドの確認
- 04フィールド単位の検証エラーテスト
- 05ハンドラー依存関係のモック
活用シーン
- 01グローバル例外処理のテスト
- 02REST API エラー形式の検証
- 03カスタムエラーペイロードの確認
- 04不正リクエストの検証応答テスト
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ giuseppe-trisciuoglio/ developer-kit --skill unit-test-exception-handlerインストール後、Claude Code で/unit-test-exception-handlerを入力して読み込まれたか確認します。
同梱ファイルreferences/examples.md
詳細
ExceptionHandler と ControllerAdvice の単体テスト
概要
このスキルでは、Spring Boot の例外ハンドラーの単体テストを作成するためのパターンを紹介します。MockMvc を使用して @ControllerAdvice クラス内の @ExceptionHandler メソッドをテストする方法を取り上げます。HTTP ステータスのアサーション、JSON レスポンスの検証、フィールド単位のバリデーションエラーのテスト、ハンドラーの依存関係のモック化などが含まれます。
使用する場面
@ExceptionHandlerメソッドの単体テストを作成する場合@ControllerAdviceによるグローバルな例外処理をテストする場合- REST API のエラーレスポンス形式を検証する場合
- コントローラーテストで例外をモック化する場合
- フィールド単位のバリデーションエラーレスポンスをテストする場合
- カスタムエラーペイロードと HTTP ステータスコードをアサートする場合
手順
- 各
@ExceptionHandlerを呼び出す特定の例外をスローするテストコントローラーを作成します MockMvcBuilders.standaloneSetup()でsetControllerAdvice()を使用して ControllerAdvice を登録します.andExpect(status().isXxx())でHTTP ステータスコードをアサートしますjsonPath("$.field")マッチャーを使用してエラーレスポンスのフィールドを検証します- 無効なペイロードを送信し、
MethodArgumentNotValidExceptionによってフィールド単位の詳細が生成されることを確認して、バリデーションエラーをテストします .andDo(print())で失敗をデバッグします。ハンドラーが呼び出されない場合は、setControllerAdvice()が呼び出されていることと、例外の型が一致していることを確認します
例
Exception Handler と Error DTO
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse(404, "Not Found", ex.getMessage());
}
@ExceptionHandler(ValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(ValidationException ex) {
return new ErrorResponse(400, "Bad Request", ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ValidationErrorResponse handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
return new ValidationErrorResponse(400, "Validation Failed", errors);
}
}
public record ErrorResponse(int status, String error, String message) {}
public record ValidationErrorResponse(int status, String error, Map<String, String> errors) {}
単体テスト
@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {
private MockMvc mockMvc;
@BeforeEach
void setUp() {
GlobalExceptionHandler handler = new GlobalExceptionHandler();
mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
.setControllerAdvice(handler)
.build();
}
@Test
void shouldReturn404WhenResourceNotFound() throws Exception {
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.error").value("Not Found"))
.andExpect(jsonPath("$.message").value("User not found"));
}
@Test
void shouldReturn400WithFieldErrorsOnValidationFailure() throws Exception {
mockMvc.perform(post("/api/users")
.contentType("application/json")
.content("{\"name\":\"\",\"email\":\"invalid\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.errors.name").value("must not be blank"))
.andExpect(jsonPath("$.errors.email").value("must be a valid email"));
}
}
@RestController
@RequestMapping("/api")
class TestController {
@GetMapping("/users/{id}") public User getUser(@PathVariable Long id) {
throw new ResourceNotFoundException("User not found");
}
@PostMapping("/users") public User createUser(@RequestBody @Valid User user) {
throw new ValidationException("Validation failed");
}
}
ベストプラクティス
- 専用の例外スロー処理を使って、各
@ExceptionHandlerメソッドを個別にテストします setControllerAdvice()を使って@ControllerAdviceインスタンスを必ず1つだけ登録します。登録を省略してはいけません- HTTP ステータスだけでなく、エラーレスポンス本文のすべてのフィールドをアサートします
- バリデーションエラーでは、フィールド名のキーとエラーメッセージの値の両方を検証します
- Spring コンテキスト全体を読み込まずにハンドラーを分離してテストするには、
MockMvcBuilders.standaloneSetup()を使用します - アサーションが失敗したときにリクエスト/レスポンスを出力するため、
.andDo(print())をチェーンして失敗をログに記録します
よくある落とし穴
- ハンドラーが呼び出されない:ビルダーで
setControllerAdvice()が呼び出されていることを確認します - JsonPath が一致しない:
.andDo(print())で実際のレスポンス構造を確認します - ステータスが 200 になる:ハンドラーメソッドに
@ResponseStatusがありません - ハンドラーが重複している:
@Orderで優先順位を制御します。より具体的な例外型が優先されます - 振る舞いではなくハンドラーのロジックをテストしている:外部依存関係をモック化し、レスポンスの変換だけをテストします
制約と警告
@ExceptionHandlerの具体性:より具体的な例外型が先に照合されます。Exception.classは、ほかに一致するものがないすべての型を捕捉します@ResponseStatusのデフォルト動作:@ResponseStatusを指定せず、ResponseEntityも返さない場合、HTTP ステータスはデフォルトで 200 になります- グローバルスコープとローカルスコープ:
@ControllerAdvice内の@ExceptionHandlerはグローバルに適用されます。コントローラー内で宣言した場合は、そのコントローラー内にのみ適用されます - ログ出力の副作用:ログを出力するハンドラーは、
verify(mockLogger).logXxx(...)で検証してください - ローカライズ:
MessageSourceを使用する場合は、異なるLocaleの値でテストし、メッセージが正しく解決されることを確認します - セキュリティコンテキスト:
AuthorizationExceptionハンドラーはSecurityContextHolderにアクセスできます。コンテキストが正しく評価されることをテストしてください
コメント
Markdown 対応