🧗🏻

MockMvc で存在しないパスにリクエストが来たときのテストをする方法

2021/01/30に公開

はじめに

Controllerクラスのテストコードを書いている時に、「存在しないパス」に対してリクエストが来たときのケースを書こうと思ったら、想定よりも難しかったので備忘の意味を込めて記事にしてみました。

動作確認のバージョン

  • Java 11
  • Spring Boot 2.3.5.RELEASE
  • JUnit 5.x

やりたいこと

  • 存在しないパスにリクエストが来た時に以下のレスポンスが返ってくるかを MockMvc を使ってテストしたい

レスポンスの期待値

  • ステータス = 404
  • レスポンスボディ = {"code":"400-001", "message":"wrong url"}

ポイント

MockMvcBuilders.standaloneSetup() を使って MockMvc をセットアップする時に addDispatcherServletCustomizer() を使って、テスト用の DispatcherServlet をカスタマイズしておくこと。

※ そうでないと、実際にテストを実行して存在しないパスにリクエストを送っても NoHandlerFoundException が発生せず、期待通りの結果がえられないため。
※ 詳しいソースは以下に掲載してあります。

詳細

今回関係するのは以下の4つのクラスです。

  • SampleController.java : テスト対象のControllerクラス
  • SampleControllerTest.java : テストクラス
  • CommonControllerAdvice.java : Controllerの共通エラーハンドラー
  • CustomDispatcherServletCustomizer.java : 【ポイント】テスト用の DispatcherServlet をカスタマイズするクラス

具体的なソースを1つずつ以下に示します。

SampleController.java

まずはテスト対象となるControllerクラスです。今回は /post に対するPOSTリクエストを受け付けるControllerを1つ作成してあります。

■ SampleController.java

@RestController
@RequiredArgsConstructor
public class SampleController {

  private final sampleService sampleService;

  // 「/post」 に対する POSTリクエストを受け付ける
  @PostMapping("post")
  public XxxResponseForm postXxx(@RequestBody @Valid XxxRequestForm xxxRequestForm) {
    return sampleService.post(xxxRequestForm);
  }

}

CommonControllerAdvice.java

次にControllerクラス共通のエラーハンドラーです。@RestControllerAdvice のついたクラスで、各Controllerで発生した例外を例外クラス別に捕捉して、共通処理を書くことができます。

今回は、存在しないパスにリクエストが来た時のハンドリングを行うことが目的なので、 NoHandlerFoundException の発生をここで待ち受けます。

なお、Springのデフォルトの設定では、存在しないパスへのリクエストで NoHandlerFoundException は発生しないため、 application.yml (設定ファイル)にて次のように設定しておきます。

spring:
  mvc:
    throw-exception-if-no-handler-found: true

この設定によって、存在しないパスへのリクエストが来た場合に NoHandlerFoundException がスローされるようになります。(※参考)

実際に、NoHandlerFoundException が発生した場合は、ステータスは400でレスポンスボディに、

{ "code": "400-001", "message": "wrong url" }

が返却されるように組んでいます。

■ CommonControllerAdvice.java

@RestControllerAdvice
public class CommonControllerAdvice {

  // ... 略 ...

  // NoHandlerFoundException が発生した場合はこのメソッドでハンドルされ、
  // カスタマイズした ResponsBody と HttpStatus.BAD_REQUEST(status=400) を返却する
  @ExceptionHandler(NoHandlerFoundException.class)
  public ResponseEntity<xxxErrorResponse> handleNoHandlerFoundException(
      NoHandlerFoundException ex) {
    return new ResponseEntity<>(xxxErrorResponse.create("400-001",
        "wrong url"), HttpStatus.BAD_REQUEST);
  }

  // ... 略 ...

}

SampleControllerTest.java

次がController用のテストクラスです。Controllerクラスのテストには MockMvc が便利ということで、例に漏れず MockMvc を利用しています。
(Controllerクラスのテストの書き方については、コチラの記事が参考になります。)

ここでポイントとなるのが、 @BeforeEachmockMvc をセットアップしている箇所です。
MockMvcBuilders.standaloneSetup() にて mockMvc をセットアップする時に、 addDispatcherServletCustomizer() を使って、カスタマイズした DispatcherServlet をセットします。

ここで、なぜ DispatcherServlet のカスタマイズが必要なのかというと、
MockMvcBuilders.standaloneSetup() にてセットアップされた場合のテスト用の DispatcherServlet はSpringの設定ファイルを参照していない(デフォルト値の)ため(※参考)です。

デフォルト値のままだと、上記の通り、存在しないパスへのリクエストで NoHandlerFoundException は発生せず、ステータス=404、レスポンスボディ=空、のレスポンスが返却され、テストがNGになってしまいます。

APIツールから実際に存在しないパスに対してリクエストを送ってみると、期待通り、ステータスは400、レスポンスボディは

{ "code": "400-001", "message": "wrong url" }

が返却されます。

なので、本ポイントを知らずにいつも通りテストを書いてしまうと、実際の挙動は問題ないのですが、テストを実行してみるとNGになる、という問題が発生してしまいます。
(私はここで苦労しました...。)

ちなみにその時のテスト実行結果と、 print() での出力内容は次の通りです。

  • 実行結果 : NG
Expected :{"code":"400-001","message":"wrong url"}
Actual   :
  • andDo(print()) での出力 : ステータス=404、ボディは空
MockHttpServletResponse:
           Status = 404
    Error message = null
          Headers = []
     Content type = null
             Body = 
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

■ SampleControllerTest.java

@SpringBootTest
@ExtendWith(SpringExtension.class)
@ActiveProfiles("test")
public class SampleControllerTest {

  @Autowired
  private SampleController sampleController;

  private MockMvc mockMvc;

  @BeforeEach
  public void setup() {
    this.mockMvc =
        MockMvcBuilders.standaloneSetup(sampleController)
            // 共通エラーハンドラーをセット( @(Rest)ControllerAdvice が付与されているクラス )
            .setControllerAdvice(new CommonControllerAdvice())
            // ※※※ ポイント ※※※
            // ↓ ここで DispatcherServletCustomizer を実装したクラスのインスタンスをセット
            .addDispatcherServletCustomizer(
              new CustomDispatcherServletCustomizer())
            .build();
  }

  @Test
  @DisplayName("存在しないパスをリクエスト")
  void notExistUrl() throws Exception {
    MockHttpServletRequestBuilder request =
        MockMvcRequestBuilders.post("/hogehoge") // 存在しないパス
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"body\" : \"any\"}");    // リクエストボディ
    mockMvc
        .perform(request)
        .andDo(print())
        .andExpect(status().is4xxClientError())
        .andExpect(content().string(
          "{\"code\":\"400-001\",\"message\":\"wrong url\"}"));
  }

}

CustomDispatcherServletCustomizer.java

そして最後の4つ目が、テスト用の DispatcherServlet をカスタマイズするクラスです。
このクラスのインスタンスを、テストクラスで MockMvc をセットアップしている箇所にて、
.addDispatcherServletCustomizer(new CustomDispatcherServletCustomizer()) という形で追加してあげればOKです。

このクラスのポイントは2つあります。

  • DispatcherServletCustomizer インターフェースを実装すること
  • setThrowExceptionIfNoHandlerFound(true)NoHandlerFoundException を発生するように設定すること

■ CustomDispatcherServletCustomizer.java

// テスト用の DispatcherServlet をカスタマイズするクラス
// DispatcherServletCustmizer インターフェースを実装(implements)する
public class CustomDispatcherServletCustomizer implements DispatcherServletCustomizer {

  @Override
  public void customize(DispatcherServlet dispatcherServlet) {
    // application.yml の spring.mvc.throw-exception-if-no-handler-found を trueにセット.
    dispatcherServlet.setThrowExceptionIfNoHandlerFound(true);
  }
}

ここまで設定すれば準備OKです。あとは実行してみましょう。

結果

以下の通りテストOKとなりました。

実行結果

SampleControllerTest > 存在しないパスをリクエスト PASSED

andDo(print()) による出力

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"code":"400-001","message":"wrong url"}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

ステータス400で、レスポンスボディにも期待通りの値が返ってくるようになりました。

参考資料

Discussion