@WebMvcTest と MockMvc で正常系のControllerテストは書けても、例外発生時に GlobalExceptionHandler(@RestControllerAdvice)が意図したレスポンスを返すかまで検証できていない人は多いはずです。

正常系のテストだけでは例外経路はカバレッジから漏れがちですよね。この記事では @WebMvcTest の続編として、例外ハンドラの検証に絞って解説します。基本的な書き方は@WebMvcTestによるController単体テストの書き方、実装方法自体は統一的なエラーレスポンスの実装方法に譲ります。

以降のコード例は given / any / status / jsonPath を static import している前提です。

前提にするController/Service/例外の構成

Controllerは薄く保ち、業務例外はService層でthrowする構成を前提にします。

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public UserResponse getUser(@PathVariable Long id) {
        return userService.findById(id);
    }
}

ResourceNotFoundException(404)、IllegalArgumentException(400)、DuplicateResourceException(409)をServiceがthrowし、GlobalExceptionHandler が対応するステータスに変換する想定です。

@WebMvcTestのコンテキストに@RestControllerAdviceは含まれるのか

@WebMvcTest はControllerだけでなく @ControllerAdvice / @RestControllerAdvice も自動スキャンするので、基本的には何も意識せずテストが書けます。

不安なときはわざと例外を投げて確認しましょう。timestamp / status / error / path を持つSpring Boot標準のエラーJSONが返ってきたら、Adviceが拾えていないサインです。

@ControllerAdvice と @RestControllerAdvice を混同すると @ResponseBody が付かずビュー解決されてしまうので、レスポンスがJSONで返ってこないときはまずこの違いを疑ってみてください。

@MockitoBeanで例外を発火させる

例外経路を確実に通すには、Service層のメソッドが例外をthrowするようモックしておきます。

@WebMvcTest(UserController.class)
class UserControllerExceptionTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    void returns404WhenUserNotFound() throws Exception {
        given(userService.findById(999L))
            .willThrow(new ResourceNotFoundException("User not found: 999"));

        mockMvc.perform(get("/api/users/999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.detail").value("User not found: 999"));
    }
}

@MockBean は非推奨なので、新しく書くテストでは @MockitoBean を使いましょう。willThrow() に渡す引数が実際のメソッドシグネチャとズレていると、モックが効かず実装本体がそのまま呼ばれてしまうので注意してください。

400・409・500もステータスごとに検証する

404と同じ形でステータスと例外クラスを変えていくだけです。

@Test
void returns400WhenArgumentIsInvalid() throws Exception {
    given(userService.findById(-1L))
        .willThrow(new IllegalArgumentException("id must be positive"));

    mockMvc.perform(get("/api/users/-1"))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.title").value("Bad Request"));
}

@Test
void returns409WhenEmailIsDuplicated() throws Exception {
    given(userService.create(any()))
        .willThrow(new DuplicateResourceException("email already exists: [email protected]"));

    mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"email\":\"[email protected]\"}"))
        .andExpect(status().isConflict())
        .andExpect(jsonPath("$.detail").value("email already exists: [email protected]"));
}

意外と忘れやすいのが、ハンドラでキャッチしていない RuntimeException が飛んできたときのフォールバックです。

@Test
void returns500WithoutLeakingStackTrace() throws Exception {
    given(userService.findById(1L)).willThrow(new RuntimeException("db connection timeout"));

    mockMvc.perform(get("/api/users/1"))
        .andExpect(status().isInternalServerError())
        .andExpect(jsonPath("$..stackTrace").doesNotExist());
}

ex.getMessage() をそのまま出していないか確認しておくと情報漏えいを防げます。マスキングの実装は運用向けGlobalExceptionHandlerを参照してください。

ProblemDetailと独自形式、どちらもjsonPathで検証できる

ProblemDetail(RFC 9457)採用時は status / title / detail に加え、Content-Typeが application/problem+json になっているかも確認しましょう(見落とすと application/json のままでも気づけません)。type / instance 未設定時は about:blank が返る点も覚えておきましょう。

mockMvc.perform(get("/api/users/999"))
    .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
    .andExpect(jsonPath("$.type").value("about:blank"))
    .andExpect(jsonPath("$.title").value("Not Found"));

自前の ErrorResponse(errorCode / message / timestamp など)形式でも、フィールド名を変えるだけで骨格は同じです。拡張プロパティの検証は運用向けGlobalExceptionHandlerを参考にしてください。

例外が増えても冗長にならないようにまとめる

例外クラスが増えると同じ形のテストが増えていきます。@ParameterizedTest で一覧管理すると、追加漏れにも気づきやすくなります。

@ParameterizedTest
@MethodSource("exceptionAndStatus")
void mapsExceptionToExpectedStatus(RuntimeException thrown, HttpStatus expected) throws Exception {
    given(userService.findById(1L)).willThrow(thrown);

    mockMvc.perform(get("/api/users/1"))
        .andExpect(status().is(expected.value()));
}

static Stream<Arguments> exceptionAndStatus() {
    return Stream.of(
        Arguments.of(new ResourceNotFoundException("not found"), HttpStatus.NOT_FOUND),
        Arguments.of(new IllegalArgumentException("bad arg"), HttpStatus.BAD_REQUEST),
        Arguments.of(new DuplicateResourceException("duplicate"), HttpStatus.CONFLICT)
    );
}

ここで渡す例外はいずれも非チェック例外(RuntimeException のサブクラス)なので willThrow() にそのまま渡せます。チェック例外を使う場合は throws 宣言の扱いが変わる点に注意してください。

@RestControllerAdviceが読み込まれないとき

@WebMvcTest(UserController.class) でもAdviceは本来自動検出されます。ただし @WebMvcTest は @SpringBootConfiguration を起点にスキャン範囲を決めるため、GlobalExceptionHandler がその対象パッケージの外にあると検出漏れが起きます。そのときは @Import で明示的に取り込みましょう。

@WebMvcTest(UserController.class)
@Import(GlobalExceptionHandler.class)
class UserControllerExceptionTest {
    // ...
}

Adviceが読み込まれていないと、@ResponseStatus のない例外(ResourceNotFoundException など)はそのまま伝播し、既定の挙動で 500 Internal Server Error になります(404にはなりません)。ボディも timestamp / status / error / path を持つSpring Boot標準のエラーJSONに変わるので見分けがつきます。

まとめ

正常系のテストと例外ハンドラのテストは見るべきポイントが違いますよね。@MockitoBean の thenThrow() で例外経路を通し、ステータスとボディの重要フィールドをjsonPathで検証する骨格さえ覚えれば、ProblemDetailでも独自形式でも対応できます。

実装側の詳細を詰めたいときは、例外処理の実装ガイドや運用向けGlobalExceptionHandlerも合わせて読んでみてください。