@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をクリーンに保ってください。