例外がテスト側へ戻る場合と、Adviceが受けてHTTP 409の応答を作る場合を上下に並べた図

実装・エラー解決

MockMvcのstandaloneSetupで@RestControllerAdviceが呼ばれない理由

この記事でわかること

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

新人SE
新人SE
予約済みの部屋ならHTTP 409を返す仕様です。でもMockMvcのテストだと、ステータスの比較に入る前にServletExceptionが出ます。
ポンコツSE
ポンコツSE
原因が予約済みの業務例外なら、次にMockMvcを作る行を見ます。Controllerだけ渡したテストでは、例外を409へ変換するAdviceも登録する必要があります。

例は会議室予約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の仕様にも、単独構成での登録方法が載っています。

関連記事Spring BootでバリデーションエラーをJSONで返す方法|RestControllerAdviceの実装例 入力の必須違反を項目別のHTTP 400へ変換する場合、AdviceでどのJSONを返すかを確認できます。 記事を読む

同じ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版」の表紙
商品画像:Amazon.co.jp
PR 入門後の参考書|Spring Framework 5向け

Spring徹底入門 第2版

株式会社NTTデータ (著), 株式会社NTTデータグループ (編集)

アプリを作った後に、DIやDB更新の仕組みを調べる。

DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。小さなWebアプリを一通り作り、Springの仕組みを詳しく学びたい人に向く参考書です。

  • Springで小さなWebアプリを作ったことがある
  • DIやトランザクションの仕組みを確認したい

Spring Framework 5向けです。Boot 3の実装に使うコード・設定は、採用している版の公式資料と照合してください。

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

関連記事Spring BootのMockMvcでPOSTが403・400になる原因|CSRF・Form・JSONの確認順 Controllerへ届く前に403や400で止まる場合は、CSRF・フォーム・JSONを切り分けます。 記事を読む 関連記事Spring Boot初心者におすすめの本3冊|現場で必要な力を身につける リクエストから応答までを追い、Spring MVCをどの本から学ぶかを決められます。 記事を読む

まとめ

予約済みR-100のPOSTで perform() が落ちたら、先に例外の原因とMockMvcの作り方を確認します。単独のMockMvcでは setControllerAdvice() で例外変換を登録し、409とJSONをセットで確認します。Springの設定からAdviceが拾われることも必要なら、MVCスライスや実際のアプリ構成で同じ入力を試します。

-実装・エラー解決
-, , ,