@WebMvcTestとMockMvcでControllerのテストを書く

1. 概要

@SpringBootTestでControllerをテストする場合、アプリケーション全体(データベース、メッセージブローカー、すべてのBeanを含む)が読み込まれます。この方法は遅く、エラーの原因を特定するのが難しいです。Spring Bootは@WebMvcTestを提供し、Webレイヤーのみを読み込みます。MockMvcと組み合わせると、実際のサーバーを起動することなく、シミュレートされたリクエストを送信できます。

この記事を読み終わると、@WebMvcTestを使ってREST Controllerをテストする方法がわかります。具体的には、ステータス、ヘッダー、返されたJSONの確認、検証のテスト、例外ハンドラーのテスト、そして一般的なエラーの回避方法です。

2. 依存関係

Controllerの場合はspring-boot-starter-web、Bean Validationの場合はspring-boot-starter-validation、テスト部分にはspring-boot-starter-testが必要です:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

spring-boot-starter-testにはJUnit 5、AssertJ、Mockito、JsonPath、Spring Testが既に含まれています。他に追加するものはありません。

3. テスト対象のController

シンプルな注文管理APIを使用します。まずはモデルとリクエストです:

public record Order(Long id, String customerName, BigDecimal total) {}

public record CreateOrderRequest(
        @NotBlank String customerName,
        @NotNull @Positive BigDecimal total) {}

サービスレイヤーはインターフェースです。テストではこれをモックします:

public interface OrderService {
    Order findById(Long id);
    Order create(CreateOrderRequest request);
}

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("注文 " + id + " が見つかりません");
    }
}

Controllerには2つのエンドポイントがあります:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderService.findById(id);
    }

    @PostMapping
    public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        Order created = orderService.create(request);
        return ResponseEntity
                .created(URI.create("/api/orders/" + created.id()))
                .body(created);
    }
}

最後に、OrderNotFoundExceptionをHTTP 404に変換する@RestControllerAdviceがあります:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleNotFound(OrderNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
}

4. @WebMvcTestを使った最初のテスト

4.1. テストフレームワーク

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private OrderService orderService;
}

各部分の動作は以下のとおりです:

  • @WebMvcTest(OrderController.class)は、MVC関連のBeanのみを含むSpringコンテキストを作成します。それはOrderController、@ControllerAdvice、Converter、Filter、WebMvcConfigurer、およびJacksonです。通常の@Service、@Repository、または@Componentはロードされません。
  • MockMvcは事前に自動構成されており、注入するだけです。
  • ObjectMapperはSpring Bootが構成するJacksonのBeanです。リクエストをシリアライズするのに使用して、テストが実際の構成と一致することを確認します。
  • @MockitoBeanはOrderServiceに対してMockitoモックを作成し、コンテキストに登録します。これにより、OrderControllerは依存関係を初期化できます。

注意: Spring Boot 3.4以降、@MockBeanは非推奨としてマークされています。org.springframework.test.context.bean.override.mockitoパッケージの@MockitoBeanを使用する必要があります。Spring Boot 3.3以下を使用している場合は、@MockBeanを使用してください。動作は同じです。

4.2. JSONを返すGETのテスト

@Test
void shouldReturnOrderWhenFound() throws Exception {
    given(orderService.findById(1L))
            .willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));

    mockMvc.perform(get("/api/orders/{id}", 1L))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.customerName").value("Nguyen Van A"))
            .andExpect(jsonPath("$.total").value(250000));
}

必要な静的インポートは以下のとおりです:

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

各ステップの説明:

  • given(...).willReturn(...)は、IDが1で呼び出されたときにモックが固定のOrderを返すように指定します。
  • perform(get(...))は、DispatcherServlet経由でGET /api/orders/1リクエストを送信します。リクエストはネットワークを通過しません。
  • andDo(print())は、完全なリクエストとレスポンスをコンソールに出力します。テストが失敗した場合、このステップは非常に便利です。
  • jsonPath("$.customerName")はJSON本体内のフィールドをクエリします。

print()の出力部分は次のようになります:

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"id":1,"customerName":"Nguyen Van A","total":250000}

5. リクエスト本体を使ったPOSTのテスト

5.1. 成功ケース

@Test
void shouldCreateOrder() throws Exception {
    var request = new CreateOrderRequest("Tran Thi B", new BigDecimal("99000"));
    given(orderService.create(request))
            .willReturn(new Order(10L, "Tran Thi B", new BigDecimal("99000")));

    mockMvc.perform(post("/api/orders")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", "/api/orders/10"))
            .andExpect(jsonPath("$.id").value(10));
}

ここで注意する2つのポイントがあります:

  • contentType(MediaType.APPLICATION_JSON)は必須です。指定しないと、Springは415 Unsupported Media Typeを返します。
  • given(orderService.create(request))は動作します。なぜならrecordは自動的にequals()を生成するからです。Controllerがデシリアライズしたリクエストは、テスト内のrequestオブジェクトと等しくなります。通常のクラスでequals()をオーバーライドしない場合は、代わりにany(CreateOrderRequest.class)を使用します。

5.2. 検証のテスト

@WebMvcTestには検証構成がロードされます。したがって、@Validは実際の実行時と同じように機能します:

@Test
void shouldReturnBadRequestWhenCustomerNameIsBlank() throws Exception {
    String invalidJson = """
            {"customerName": "", "total": -5}
            """;

    mockMvc.perform(post("/api/orders")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(invalidJson))
            .andExpect(status().isBadRequest());

    verify(orderService, never()).create(any());
}

verify(orderService, never()).create(any())行は、データが無効な場合、サービスが呼び出されないことを確認します。これはまさにあなたが保護したい動作です。検証はWebレイヤーでリクエストを直ちにブロックします。

このテストはJSONテキストブロックを使用します。ObjectMapperではなく、この記述方法を使用すると、recordが表現できないペイロード(例えば、誤ったデータ型のJSON)も送信できます。

6. 例外ハンドラーのテスト

@WebMvcTestは自動的に@RestControllerAdviceをロードするため、例外からHTTPステータスへのマッピングをテストすることもできます:

@Test
void shouldReturnNotFoundWhenOrderDoesNotExist() throws Exception {
    given(orderService.findById(99L)).willThrow(new OrderNotFoundException(99L));

    mockMvc.perform(get("/api/orders/{id}", 99L))
            .andExpect(status().isNotFound())
            .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.detail").value("注文 99 が見つかりません"));
}

レスポンス本体はRFC 9457(Problem Details)標準に従います:

{"type":"about:blank","title":"Not Found","status":404,"detail":"注文 99 が見つかりません","instance":"/api/orders/99"}

これは、純粋なユニットテスト(orderController.getOrder(99L)を直接呼び出す)と比較して、@WebMvcTestの強力な点です。純粋なユニットテストは、スローされる例外のみを確認します。クライアントが実際に受け取るステータスと本体を確認することはできません。

7. MockMvcTesterでAssertJスタイルのアサーション

Spring Boot 3.4(Spring Framework 6.2)以降、AssertJがクラスパスに存在する場合、Spring BootはさらにMockMvcTesterを自動構成します。このBeanを使用すると、andExpectのチェーンの代わりにAssertJを使用してアサーションを書くことができます:

@WebMvcTest(OrderController.class)
class OrderControllerAssertJTest {

    @Autowired
    private MockMvcTester mvc;

    @MockitoBean
    private OrderService orderService;

    @Test
    void shouldReturnOrderWhenFound() {
        given(orderService.findById(1L))
                .willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));

        assertThat(mvc.get().uri("/api/orders/{id}", 1L))
                .hasStatusOk()
                .hasContentType(MediaType.APPLICATION_JSON)
                .bodyJson()
                .extractingPath("$.customerName")
                .isEqualTo("Nguyen Van A");
    }
}

MockMvcと比較して、この記述方法には3つの利点があります:

  • throws Exceptionは不要です。
  • MockMvcResultMatchersから多くの静的インポートを覚える必要がありません。
  • AssertJのエラーメッセージは通常、理解しやすいです。

2つのAPIは機能上同等です。1つを選択し、プロジェクト全体で一貫して使用してください。

8. 読み込まれるものを制限する

8.1. Controllerを指定する

パラメータなしで@WebMvcTestを書く場合、Springはアプリケーション内のすべてのControllerを読み込みます。その場合、関連のないControllerを含むすべてのControllerの依存関係をモックする必要があります。したがって、常にテスト対象のControllerを指定してください:

@WebMvcTest(OrderController.class)

8.2. 特定のBeanを追加で読み込む

場合によっては、Controllerが副作用のない小さなBeanを使用することがあります。例えばOrderMapperなど。このBeanをモックすることは価値をもたらしません。@Importを使用して実際のBeanをロードできます:

@WebMvcTest(OrderController.class)
@Import(OrderMapper.class)
class OrderControllerTest {
    // ...
}

8.3. Spring Securityがある場合

spring-boot-starter-securityがクラスパスに存在する場合、@WebMvcTestはセキュリティフィルターチェーン全体をロードします。上記のテストは401または403を返します。次の依存関係を追加する必要があります:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

その後、ユーザーを検証し、データ変更リクエストにCSRFトークンを含めます:

@Test
@WithMockUser(roles = "USER")
void shouldCreateOrderWhenAuthenticated() throws Exception {
    // ... 5.1のようにorderServiceをスタブ化
    mockMvc.perform(post("/api/orders")
                    .with(csrf())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("""
                            {"customerName": "Le Van C", "total": 120000}
                            """))
            .andExpect(status().isCreated());
}

注意: あなたが定義したクラスSecurityFilterChain(@Configurationとマークされている)は、すべてのバージョンで自動的にロードされるわけではありません。独自の認可ルールをテストしたい場合は、@Import(SecurityConfig.class)を追加してください。

9. よくあるエラー

テスト起動時のNoSuchBeanDefinitionException。 Controllerが、Webレイヤーに属さず、モックしていないBeanに依存しています。修正方法:そのBeanに@MockitoBeanを追加するか、@Importします。

クラス@SpringBootApplicationのアノテーションがテストを破壊する。 例えば、@EnableJpaRepositoriesや@EnableSchedulingをメインクラスに直接配置します。@WebMvcTestは依然このクラスを読み込むため、DataSourceが不足しているリポジトリを作成しようとしてエラーになります。修正方法:それらのアノテーションを別の@Configurationクラスに移動します。

contentTypeを忘れる。 結果として、期待するステータスの代わりに415が返ります。

MockMvcを使用してロジックを確認しているが、ロジックはサービスにある。 @WebMvcTestはWebレイヤーの職務のみを確認するべきです:

  • ルーティング、
  • シリアライゼーション、
  • 検証、
  • ステータスコード、
  • 例外マッピング。

ビジネスロジックは、純粋なユニットテストでサービスを別途テストしてください。

オーバーモック。 テストが5、6個のメソッドをスタブ化する必要がある場合、Controllerが多くのことをやりすぎている可能性があります。これはスタブを追加するのではなく、リファクタリングするための信号です。

注意: @WebMvcTestは、データベースを通ったエンドツーエンドのフローを確認したい場合、またはサーバーの実際の動作(ポート、HTTP/2、コンテナのフィルター)をテストしたい場合には使用しないでください。その場合は、@SpringBootTest(webEnvironment = RANDOM_PORT)をTestRestTemplateまたはWebTestClientと一緒に使用してください。アプリケーションがWebFluxを使用している場合、対応するスライスは@WebMvcTestではなく@WebFluxTestです。

10. 結論

@WebMvcTestは、Controllerを高速かつ集中してテストするのに役立ちます。Webレイヤーのみをロードし、背後の依存関係は@MockitoBeanで置き換えます。MockMvcまたはMockMvcTesterと組み合わせると、クライアントが実際に見る動作をテストできます:ステータスコード、ヘッダー、JSON、検証、例外マッピング。

覚えておくべき主なルール:

  • @WebMvcTestでControllerを常に指定してください。
  • Controllerが必要な部分をモックしてください。
  • リクエスト本体を送信するときにcontentTypeを忘れないでください。
  • テストスライスが余分な構成を引き込まないように、クラス@SpringBootApplicationをクリーンに保ってください。
spring-boottestingmockmvcjunit5webmvctest