mcpField
U
unit-test-exception-handler

unit-test-exception-handler

github.com/giuseppe-trisciuoglioテスト・品質3653.7k 回インストール7 週間前に更新
リポジトリ

unit-test-exception-handler について

Spring Boot 開発者向けに、MockMvc を使って `@ControllerAdvice` と `@ExceptionHandler` をテストする方法を紹介します。HTTP ステータスや JSON 応答、フィールド単位の検証エラーを確認できます。

主な機能

  1. 01MockMvc による分離テスト
  2. 02HTTP ステータスコードの検証
  3. 03JSON エラーフィールドの確認
  4. 04フィールド単位の検証エラーテスト
  5. 05ハンドラー依存関係のモック

活用シーン

  1. 01グローバル例外処理のテスト
  2. 02REST API エラー形式の検証
  3. 03カスタムエラーペイロードの確認
  4. 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 ステータスコードをアサートする場合

手順

  1. 各 @ExceptionHandler を呼び出す特定の例外をスローするテストコントローラーを作成します
  2. MockMvcBuilders.standaloneSetup() で setControllerAdvice() を使用して ControllerAdvice を登録します
  3. .andExpect(status().isXxx()) でHTTP ステータスコードをアサートします
  4. jsonPath("$.field") マッチャーを使用してエラーレスポンスのフィールドを検証します
  5. 無効なペイロードを送信し、MethodArgumentNotValidException によってフィールド単位の詳細が生成されることを確認して、バリデーションエラーをテストします
  6. .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 にアクセスできます。コンテキストが正しく評価されることをテストしてください

コメント