MockMvcではerror/404.htmlやerror.htmlをテスト不可能。その理由と代替策

# 環境
- Java 21
- Spring Boot 3.2
- Thymeleaf 3.1

# やりたいこと（でも出来なかった）
MockMvcを利用したコントローラーの単体テストで、以下のテストを試みました。

- コントローラーメソッド内で想定外の例外（＝ExceptionHandlerを作っていない例外）がスローされたら、error.htmlの内容がレスポンスされる
- 存在しないURLを指定したら、error/404.htmlの内容がレスポンスされる

具体的なテストコードはこちらです。

```java:MockMvcを使ったテストコード
@SpringBootTest  // webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT を指定しても結果は同じ
@AutoConfigureMockMvc
public class SampleControllerTest {
    @MockBean
    SampleService sampleService;

    @Autowired
    MockMvc mvc;

    @Nested
    @DisplayName("想定外の例外")
    class ErrorTest {
        @Test
        @DisplayName("想定外の例外がスローされたら、エラー画面に遷移する")
        void exception() throws Exception {
            when(sampleService.doSomething()).thenThrow(new RuntimeException("想定外の例外が発生しました。"));
            mvc.perform(get("/"))
                    .andExpect(status().isInternalServerError())
                    .andExpect(view().name("error"));
        }
    }

    @Nested
    @DisplayName("404エラー")
    class NotFoundTest {
        @Test
        @DisplayName("存在しないURLにアクセスしたら、404エラー画面に遷移する")
        void notFound() throws Exception {
            mvc.perform(get("/xxx"))
                    .andExpect(status().isNotFound())
                    .andExpect(view().name("error/404"));
        }
    }
}
```

このテストを実行すると結果がNGとなりました。

```txt:想定外の例外のテスト結果
jakarta.servlet.ServletException: Request processing failed: java.lang.RuntimeException: 想定外の例外が発生しました。
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1022)
...
Caused by: java.lang.RuntimeException: 想定外の例外が発生しました。
...
```

```txt:404エラーのテスト結果
ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 404
    Error message = No static resource xxx.
          Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"]
     Content type = null
             Body = 
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

java.lang.AssertionError: No ModelAndView found
```

# なぜNGになったのか？
error/404.htmlやerror.htmlは[`BasicErrorController`](https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/BasicErrorController.java)を通じて[`DefaultErrorViewResolver`](https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/DefaultErrorViewResolver.java)によって処理されます。
`BasicErrorController`は[`ErrorMvcAutoConfiguration`](https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/ErrorMvcAutoConfiguration.java)でBean定義されています。

```java:ErrorMvcAutoConfiguration.java（一部引用）
@AutoConfiguration(before = WebMvcAutoConfiguration.class)
@ConditionalOnWebApplication(type = Type.SERVLET)  // コレが原因
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class })
@EnableConfigurationProperties({ ServerProperties.class, WebMvcProperties.class })
public class ErrorMvcAutoConfiguration {
    ...
    @Bean
    @ConditionalOnMissingBean(value = ErrorController.class, search = SearchStrategy.CURRENT)
    public BasicErrorController basicErrorController(ErrorAttributes errorAttributes,
        ObjectProvider<ErrorViewResolver> errorViewResolvers) {
        return new BasicErrorController(errorAttributes, this.serverProperties.getError(),
                errorViewResolvers.orderedStream().toList());
	}
    ...
}
```

1. この`ErrorMvcAutoConfiguration`には`@ConditionalOnWebApplication(type = Type.SERVLET)`が付加されている
1. `MockMvc`を使っている場合はこの条件に合致しない（デバッガで追って確かめました）
1. `ErrorMvcAutoConfiguration`自体が無効化される
1. `BasicErrorController`もBean定義されない
1. よって、error.htmlなどが使われない

ちなみに、テストクラスに付いている`@SpringBootTest`に`webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT`を追加してもダメでした。これについては、ソースを追っても理由が分かりませんでした・・・。

# 代替策
ということで、`MockMvc`を使ったテストでは無理そうです。なので、他の手段を使うしかありません。

1. テストコードを書くのは諦めて、ブラウザから手動でテストする。
1. SeleniumやPlaywrightを使ってテストする。
1. `TestRestTemplate`を使ってテストする。

最後の方法のコードはこんな感じです。

```java:TestRestTemplateを使ったテストコード
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SampleApplicationIntegrationTest {

    @Autowired
    TestRestTemplate restTemplate;

    @MockBean
    SampleService idolService;

    @Nested
    @DisplayName("想定外の例外")
    class ErrorTest {
        @Test
        @DisplayName("想定外の例外がスローされたら、エラー画面に遷移する")
        void exception() throws Exception {
            when(sampleService.doSomething()).thenThrow(new RuntimeException("想定外の例外が発生しました。"));
            HttpHeaders httpHeaders = new HttpHeaders();
            httpHeaders.setAccept(List.of(MediaType.TEXT_HTML));
            RequestEntity<Object> requestEntity = new RequestEntity<>(httpHeaders, HttpMethod.GET, URI.create("/"));
            ResponseEntity<String> responseEntity = restTemplate.exchange(requestEntity, String.class);
            assertAll(
                    () -> assertEquals(500, responseEntity.getStatusCode().value()),
                    () -> assertTrue(responseEntity.getBody().contains("エラーが発生しました。システム管理者に問い合わせてください。"))
            );
        }
    }

    @Nested
    @DisplayName("404エラー")
    class NotFoundTest {
        @Test
        @DisplayName("存在しないURLにアクセスしたら、404エラー画面に遷移する")
        void notFound() throws Exception {
            HttpHeaders httpHeaders = new HttpHeaders();
            httpHeaders.setAccept(List.of(MediaType.TEXT_HTML));
            RequestEntity<Object> requestEntity = new RequestEntity<>(httpHeaders, HttpMethod.GET, URI.create("/xxx"));
            ResponseEntity<String> responseEntity = restTemplate.exchange(requestEntity, String.class);
            assertAll(
                    () -> assertEquals(404, responseEntity.getStatusCode().value()),
                    () -> assertTrue(responseEntity.getBody().contains("指定されたURLは存在しません。"))
            );
        }
    }
}
```

これならテストはOKになりました。

# 追記2024-05-04
公式リファレンスにも書いてありました！

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#features.testing.spring-boot-applications.with-mock-environment

> This means that, whilst you can test your MVC layer throws and handles exceptions as expected, you cannot directly test that a specific custom error page is rendered. If you need to test these lower-level concerns, you can start a fully running server as described in the next section.

日本語訳

> これはつまり、MVCレイヤーが期待通りに例外をスローしたりハンドリングしたりするかをテストできると同時に、特定のカスタムエラーページが表示されるかを直接テストすることはできません。これらの低レイヤーな関心事をテストする必要があるならば、次節で解説されるようにサーバーを完全に起動（訳註: `@SpringBootTest(webEnvironment = RANDOM_PORT)`）してください。