MockMvc で存在しないパスにリクエストが来たときのテストをする方法
はじめに
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クラスのテストの書き方については、コチラの記事が参考になります。)
ここでポイントとなるのが、 @BeforeEach
で mockMvc
をセットアップしている箇所です。
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で、レスポンスボディにも期待通りの値が返ってくるようになりました。
参考資料
- https://terasolunaorg.github.io/guideline/5.4.1.RELEASE/ja/UnitTest/ImplementsOfUnitTest/UsageOfLibraryForTest.html#usageoflibraryfortestmockmvcoverview
- https://github.com/spring-projects/spring-framework/issues/18849
- https://spring.pleiades.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#spring.mvc.throw-exception-if-no-handler-found
- https://qiita.com/ryo2132/items/ec10116238e1e1f333a1
Discussion