この記事でわかること
standaloneSetup() で作ったMockMvcには、アプリの @RestControllerAdvice が自動登録されません。同じ予約リクエストを送り、未登録では perform() が例外を投げ、setControllerAdvice() で登録するとHTTP 409とJSONが返るところまで確かめます。


例は会議室予約APIです。画面は RESERVATION_CONFLICT を受け取ったら「ほかの部屋を選んでください」と案内します。先にJavaのテストで予約済みを拒否できていても、HTTPのステータスとJSONはSpring MVCを通して確認する必要があります。
実行サンプル
予約APIのController・Service・Adviceと、JUnit・MockMvcの6テスト、pom.xmlを収録。JDK 17、Maven 3.9系、Spring Boot 3.5.16を使います。ZIPを展開して mvn clean test を実行すると、Adviceなしで投げる例外と、登録後のHTTP 409を確認できます。
同じ予約済みの部屋を、JavaとHTTPでどう確かめるか
空きの R-200 へ POST /rooms/R-200/reservations を送ると、HTTP 201と {"roomId":"R-200","status":"RESERVED"} を返します。予約済みの R-100 へ同じ操作をすると、Serviceは ReservationConflict を投げ、APIはHTTP 409と {"code":"RESERVATION_CONFLICT","roomId":"R-100"} を返す契約です。
配布サンプルの ReservationService は、R-100を予約済みとする固定の処理です。ReservationServiceTest は、R-100で例外に部屋IDが入ることと、R-200では例外が出ないことをJUnitで確認します。ReservationController はURLの部屋IDをServiceへ渡し、成功時に201を返します。サンプルは会議室の検索や予約の保存を行いません。
ReservationService.javaの全文を見る
package example.reservation;
import org.springframework.stereotype.Service;
@Service
class ReservationService {
void reserve(String roomId) {
if ("R-100".equals(roomId)) {
throw new ReservationConflict(roomId);
}
}
static final class ReservationConflict extends RuntimeException {
private final String roomId;
ReservationConflict(String roomId) {
super("Room is already reserved: " + roomId);
this.roomId = roomId;
}
String roomId() {
return roomId;
}
}
}ReservationController.javaの全文を見る
package example.reservation;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
class ReservationController {
private final ReservationService service;
ReservationController(ReservationService service) {
this.service = service;
}
@PostMapping("/rooms/{roomId}/reservations")
@ResponseStatus(HttpStatus.CREATED)
ReservationResult reserve(@PathVariable("roomId") String roomId) {
service.reserve(roomId);
return new ReservationResult(roomId, "RESERVED");
}
record ReservationResult(String roomId, String status) {}
}Adviceが未登録だと、perform() が ServletException を投げる
ReservationStandaloneTest の withoutAdvicePerformThrowsServletException() を見ます。テストはControllerを new して、standaloneSetup(controller).build() でMockMvcを作ります。予約済みのR-100へPOSTすると、perform() が jakarta.servlet.ServletException を投げます。その原因は、Serviceが投げた ReservationConflict です。
未登録の構成で perform() の後ろに andExpect(status().isConflict()) を書いても、そこには到達しません。実行結果の違いは、HTTPステータスの期待値を修正しても解決しません。まず失敗ログの Caused by と、テストがMockMvcをどの方法で作っているかを確認します。例外の中身が想定の業務例外なら、予約の判定と、例外をHTTPへ変換する処理を分けて追えます。
standaloneSetup() はアプリのAdviceを探しにいかない
standaloneSetup() は渡したControllerを動かすために最小限のMVC構成を用意します。ReservationAdvice に @RestControllerAdvice を付けていても、アプリのコンポーネントスキャンは行われません。予約済みの例外を受け取る @ExceptionHandler は、このテストの構成には登録されていません。
package example.reservation;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
class ReservationAdvice {
@ExceptionHandler(ReservationService.ReservationConflict.class)
@ResponseStatus(HttpStatus.CONFLICT)
ReservationError handleConflict(ReservationService.ReservationConflict exception) {
return new ReservationError("RESERVATION_CONFLICT", exception.roomId());
}
record ReservationError(String code, String roomId) {}
}handleConflict() は ReservationConflict を受け取り、@ResponseStatus(HttpStatus.CONFLICT) で409、ReservationError で画面向けのJSONを返します。Adviceが存在することと、今走らせているテストで使われることは、別々に確認できます。Spring Framework 6.2のsetControllerAdviceの仕様にも、単独構成での登録方法が載っています。
同じAdviceを登録して、409とエラーコードを検証する
registeredAdviceReturns409AndJson() では、.setControllerAdvice(new ReservationAdvice()) を build() の前に入れます。Controller、入力、Serviceが投げる例外は未登録の場合と同じです。登録したものは、本番コードで使う ReservationAdvice のインスタンスです。
package example.reservation;
import jakarta.servlet.ServletException;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class ReservationStandaloneTest {
private final ReservationController controller =
new ReservationController(new ReservationService());
@Test
void withoutAdvicePerformThrowsServletException() {
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
ServletException exception = assertThrows(ServletException.class, () ->
mvc.perform(post("/rooms/{roomId}/reservations", "R-100")));
assertInstanceOf(ReservationService.ReservationConflict.class, exception.getCause());
}
@Test
void registeredAdviceReturns409AndJson() throws Exception {
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new ReservationAdvice())
.build();
mvc.perform(post("/rooms/{roomId}/reservations", "R-100"))
.andExpect(status().isConflict())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value("RESERVATION_CONFLICT"))
.andExpect(jsonPath("$.roomId").value("R-100"));
}
@Test
void freeRoomStillReturns201() throws Exception {
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
mvc.perform(post("/rooms/{roomId}/reservations", "R-200"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("RESERVED"))
.andExpect(jsonPath("$.roomId").value("R-200"));
}
}ステータスだけだと、画面が必要とする code や roomId の欠落を見逃します。修正版は409に加え、Content-TypeがJSONであること、code が RESERVATION_CONFLICT、roomId が R-100 であることを確かめます。空きのR-200が未登録の構成でも201になるため、正常系だけが通っていてもAdviceの登録までは保証できないと分かります。
自動で見つかるかを試すなら @WebMvcTest でも確認する
ここまでで確かめたのは、ControllerとAdviceを組み合わせたときのHTTP契約です。AdviceをSpringのMVC用テスト構成で自動検出できることも調べるため、配布サンプルには ReservationMvcSliceTest を入れました。@WebMvcTest(ReservationController.class) で、Bootが用意したMockMvcに同じR-100のPOSTを送ります。
package example.reservation;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(ReservationController.class)
@Import(ReservationService.class)
class ReservationMvcSliceTest {
@Autowired
private MockMvc mvc;
@Test
void mvcSliceFindsAdviceInApplicationPackage() throws Exception {
mvc.perform(post("/rooms/{roomId}/reservations", "R-100"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("RESERVATION_CONFLICT"))
.andExpect(jsonPath("$.roomId").value("R-100"));
}
}この例はアプリのパッケージ内にある ReservationAdvice をMVCスライスが拾います。@Import(ReservationService.class) はControllerが必要とするServiceだけを追加します。DBや外部APIに依存するServiceなら、テストの目的に応じてテスト用のBeanや @MockitoBean を使って、HTTP契約に必要な戻り値や例外を用意できます。プロファイルやアプリ全体のBean構成も確認したいなら、@SpringBootTest と @AutoConfigureMockMvc でその構成を起動して同じリクエストを試します。この組み合わせもMockMvcなので、ブラウザやネットワークを経由する確認は別工程です。Spring Boot 3.5のテスト設定で、各方法の読み込み範囲を確認できます。
ZIPを展開して mvn clean test を実行すると、ServiceのJUnitが2件、単独のMockMvcが3件、MVCスライスが1件で、合計6件です。修正版の setControllerAdvice() を外すと、409とJSONを検証する1件が ServletException で落ちます。手動登録で通った結果を、MVCスライスの自動検出結果と分けて確認できます。
ReservationServiceTest.javaの全文を見る
package example.reservation;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ReservationServiceTest {
private final ReservationService service = new ReservationService();
@Test
void rejectsReservedRoom() {
var exception = assertThrows(ReservationService.ReservationConflict.class,
() -> service.reserve("R-100"));
assertEquals("R-100", exception.roomId());
}
@Test
void allowsFreeRoom() {
assertDoesNotThrow(() -> service.reserve("R-200"));
}
}ReservationApplication.javaの全文を見る
package example.reservation;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ReservationApplication {
public static void main(String[] args) {
SpringApplication.run(ReservationApplication.class, args);
}
}pom.xmlの全文を見る
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>example</groupId>
<artifactId>mockmvc-standalone-controller-advice</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>レビューでは例外の原因と、テストが読み込む範囲を揃える
R-100の
perform()が投げる例外の原因は、想定どおりReservationConflictでした。単独のMockMvcへ本番のAdviceを明示登録し、HTTP 409とRESERVATION_CONFLICTを確認しました。Adviceを自動で拾うことはMVCスライスの別テストで検証しています。
perform()が例外を投げたのか、andExpect()の比較で落ちたのかを切り分けた。Caused byの例外型を、本番の@ExceptionHandlerが扱う型と突き合わせた。- テストの目的に合わせて、単独のMockMvcへの手動登録と、Spring側の自動検出を分けた。
- HTTPステータスと、画面が使うJSONの項目を両方確認した。
MockMvcの作り方から整理したいときの1冊
『Spring徹底入門 第2版』のMockMvcのセットアップを扱う箇所は、Controllerを直接組み立てる方法と、Springの設定を利用する方法の違いを整理したいときに使えます。読みながら、自分のテストがどのBeanや設定を使っているかを書き出してみてください。この記事の配布サンプルはSpring Boot 3.5.16で確認しています。
Spring徹底入門 第2版
アプリを作った後に、DIやDB更新の仕組みを調べる。
DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。小さなWebアプリを一通り作り、Springの仕組みを詳しく学びたい人に向く参考書です。
- Springで小さなWebアプリを作ったことがある
- DIやトランザクションの仕組みを確認したい
Spring Framework 5向けです。Boot 3の実装に使うコード・設定は、採用している版の公式資料と照合してください。
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
予約済みR-100のPOSTで perform() が落ちたら、先に例外の原因とMockMvcの作り方を確認します。単独のMockMvcでは setControllerAdvice() で例外変換を登録し、409とJSONをセットで確認します。Springの設定からAdviceが拾われることも必要なら、MVCスライスや実際のアプリ構成で同じ入力を試します。



