Spring BootのMockMvcで403と400になる原因を解説する記事のアイキャッチ。Java道場風の空間で若手エンジニアがCSRFとPOSTテストを調査するイラスト。

設計・テスト・レビュー

Spring BootのMockMvcでPOSTが403・400になる原因|CSRF・Form・JSONの確認順

AIによる要約

MockMvcのPOSTが403なら、まずCSRFと権限を切り分けます。.with(csrf())を足しても、必要な認証ユーザーや権限がなければ成功しません。400は入力変換やValidationを調べますが、FormのエラーをBindingResultで受ける画面では200で戻る場合があります。Securityを有効にした実行例で、403・401・400・200と登録成功を区別します。

新人SE
新人SE
ブラウザでは登録できるのに、MockMvcのPOSTテストだけ403になります。Controllerの実装が悪いのでしょうか?
ポンコツSE
ポンコツSE
403ならSecurity Filterで止まっている可能性があります。Controllerの引数を直す前に、認証とCSRFを確認しましょう。

MockMvcはHTTPサーバーを起動せず、DispatcherServletやSpring MVCの仕組みを通してControllerをテストできます。そのため、メソッドを直接呼ぶ単体テストでは見つからないMapping、変換、Validation、Securityの問題も検出します。

「CSRFを付けたのに通らない」「不正なFormなのに200になる」という場合は、入力値を直す前にテストのSecurity設定とControllerの引数を確認します。以下では同じ環境で条件を一つずつ変え、失敗時にServiceが呼ばれていないことも確かめます。

この記事のポイント

  • 403は認証・権限・CSRFを先に確認する
  • CSRF保護下のPOSTにはテスト用トークンを付ける
  • 400はFormとJSONで確認項目を分ける
  • .paramはForm、.contentはJSON本文に使う
  • andDo(print())でリクエストと解決例外を確認する

最初に見る:403・401・400・200の違い

次の表は、CSRF有効・ORDER権限必須・HTTP Basic認証の設定で実行した結果です。Formはエラー時に入力画面へ戻し、JSON APIは@Valid @RequestBodyのエラーを標準の例外処理へ渡します。具体的な設定は次の節に示します。

送った条件結果判断・次に直す所
正しい権限+正常Form、CSRFなし/不正403入力より先にCSRFを修正
CSRFあり、認証ユーザーなし401このHTTP Basic設定では認証が必要
CSRFあり、認証済みだがREADER権限403必要なのはORDER権限
認証もCSRFもなし403この構成ではCSRFで先に拒否される
ORDER+CSRF+正常Form302 → /ordersServiceが1回呼ばれ、302と遷移先URLを確認(GET先画面は対象外)
ORDER+CSRF+Formの型・入力エラー、BindingResultあり200+fieldError入力画面へ戻る。登録成功ではない
同じFormエラー、BindingResultなし400引数解決時のエラーをControllerで受けていない
ORDER+CSRF+不正JSON/必須・範囲違反400JSON変換またはValidationを修正
ORDER+CSRF+JSON、Content-Typeがtext/plain415application/jsonへ修正
ORDER+CSRF+正常JSON200+JSON本文Serviceが1回呼ばれる

401やリダイレクトは認証の入口、400の返し方は例外処理の設定でも変わります。表の数値をすべてのアプリへ当てはめず、自分のSecurityFilterChainとControllerに合う期待値を決めてください。

検証条件:Securityを有効にしたMockMvc

実行環境はJava 17.0.8、Spring Boot 3.5.16、Spring Framework 6.2.19、Spring Security 6.5.11、Hibernate Validator 8.0.1.Finalです。Web・Thymeleaf・Validationとテストの依存に加え、spring-boot-starter-securityとテスト用のspring-security-testを使いました。SecurityのバージョンはBootの依存管理へ揃えています。

検証用の設定は次のとおりです。/orders/no-bindingはBindingResultを外した比較用のURLです。user()で認証済みの利用者を作るため、実際のパスワード照合やログイン画面は今回のテスト範囲に含めません。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/orders", "/orders/no-binding", "/api/orders")
                        .hasRole("ORDER")
                        .anyRequest().denyAll())
                .csrf(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults())
                .build();
    }

    // 検証では user() で認証済みユーザーを用意する。実際のログイン処理は対象外。
    @Bean
    UserDetailsService userDetailsService() {
        return new InMemoryUserDetailsManager();
    }
}

以下は既存の@SpringBootApplicationがあるSpring Bootプロジェクトへ置くコードの抜粋です。各Javaファイルは同じパッケージへ配置します。読みやすさのためpackage宣言、Controllerの一部import・クラス宣言・コンストラクタを省略しています。orderServiceはコンストラクタで受け取ってフィールドへ保持してください。

テストではこの設定を@Importし、@WebMvcTestへSecurity Filterを含めます。Serviceだけをモックへ置き換えます。以下のテストメソッドは、このクラスへ入れる抜粋です。ControllerとDTOは後の節に示します。

import com.fasterxml.jackson.databind.ObjectMapper;
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.http.MediaType;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

@WebMvcTest({OrderController.class, OrderApiController.class})
@Import(SecurityConfig.class)
class MockMvcPostTest {
    @Autowired MockMvc mockMvc;
    @Autowired ObjectMapper objectMapper;
    @MockitoBean OrderService orderService;

    // 以下の節の @Test メソッドをここへ追加する。
}

実行時には、両URLのFilterChainにCsrfFilterが含まれることも検査しました。addFilters = falseなどで保護を外していない状態で、以降の結果を比較しています。@MockitoBeanはこのFramework 6.2の構成で使用しています。古い案件では利用中のバージョンと既存テストに揃えてください。

403を再現し、CSRFを足して成功まで確認する

認証ユーザー・権限・Formの入力値を固定し、CSRFの有無だけを変えます。最初の403ではServiceを呼ばず、トークンを足した後は302と登録処理の呼出しまで確認します。

@Test
void addingCsrfFixesTheAuthenticatedFormPost() throws Exception {
    // 失敗を確認:利用者と入力値は正しくても、CSRFがないと403。
    mockMvc.perform(post("/orders")
                    .with(user("operator").roles("ORDER"))
                    .param("customerId", "10").param("quantity", "2"))
            .andExpect(status().isForbidden());
    verifyNoInteractions(orderService);

    // 修正を確認:同じリクエストへ有効なCSRFを足す。
    mockMvc.perform(post("/orders")
                    .with(user("operator").roles("ORDER")).with(csrf())
                    .param("customerId", "10").param("quantity", "2"))
            .andExpect(status().isFound())
            .andExpect(redirectedUrl("/orders"));
    verify(orderService).create(new OrderRequest(10L, 2));
}

不正なトークンも.with(csrf().useInvalidToken())で再現でき、同じ正常入力・ORDER権限でも403でした。.with(csrf())は有効なテスト用トークンを送る操作です。失敗を消すために本番やテストのCSRF保護を無効化する必要はありません。公式のCSRFテスト方法でも両方の送信方法が示されています。

CSRFを付けても通らない:認証と権限を分ける

@Test
void csrfDoesNotReplaceAuthenticationOrPermission() throws Exception {
    // CSRFだけでは認証されない。このHTTP Basic設定では401。
    mockMvc.perform(post("/orders")
                    .with(csrf())
                    .param("customerId", "10").param("quantity", "2"))
            .andExpect(status().isUnauthorized());

    // 認証済みでも、必要なORDER権限がなければ403。
    mockMvc.perform(post("/orders")
                    .with(user("reader").roles("READER")).with(csrf())
                    .param("customerId", "10").param("quantity", "2"))
            .andExpect(status().isForbidden());
    verifyNoInteractions(orderService);
}

hasRole("ORDER")に対応するのはuser("operator").roles("ORDER")です。rolesにはROLE_を付けず、hasAuthorityで指定する権限文字列との違いを確認します。テストユーザーの設定方法はSpring Securityの公式資料にもあります。

今回の未認証リクエストはHTTP Basicの設定で401でした。Formログインを使う構成では、ログイン画面へのリダイレクトになることもあります。また、認証もCSRFもないPOSTは先にCSRFで拒否されて403になりました。「未認証なら必ず401」「403なら必ずCSRF」とは判断できません。

Formの入力エラーは、BindingResultがあると200で戻る

Formでは.paramの名前をプロパティへ合わせます。この例のOrderFormは、文字列から型変換するためのsetterを持つ入力用クラスです。

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;

public class OrderForm {
    @NotNull
    private Long customerId;
    @NotNull
    @Min(1)
    private Integer quantity;

    public Long getCustomerId() { return customerId; }
    public void setCustomerId(Long customerId) { this.customerId = customerId; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

次は@Controllerを付けたOrderControllerのメソッドです。orderServiceはコンストラクタで受け取ります。Formの直後へBindingResultを置き、エラーならorders/inputへ戻す契約にしています。

@PostMapping("/orders")
public String create(
        @Valid @ModelAttribute("orderForm") OrderForm form,
        BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return "orders/input";
    }
    orderService.create(new OrderRequest(form.getCustomerId(), form.getQuantity()));
    return "redirect:/orders";
}
@Test
void formTypeErrorWithBindingResultReturns200AndFieldError() throws Exception {
    var result = mockMvc.perform(post("/orders")
                    .with(user("operator").roles("ORDER"))
                    .with(csrf())
                    .param("customerId", "10").param("quantity", "二個"))
            .andExpect(status().isOk())
            .andExpect(view().name("orders/input"))
            .andExpect(model().attributeHasFieldErrorCode("orderForm", "quantity", "typeMismatch"))
            .andReturn();
    verifyNoInteractions(orderService);
}

Thymeleafで200の入力画面まで追試するには、src/main/resources/templates/orders/input.htmlへ次の最小テンプレートも置きます。この検証ではViewの解決とfieldErrorを確認し、画面上の項目別メッセージ表示は後述の関連記事で扱います。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>注文入力</title>
</head>
<body>
    <p>入力エラーを確認してください。</p>
</body>
</html>

このコードでquantity=二個を送ると、200・入力画面・quantityのtypeMismatchになり、Serviceは呼ばれません。200だけを見て登録成功とは判定しないでください。quantity=0はMinエラー、quantityを誤ってcountで送った場合はNotNullエラーになりました。

比較用の/orders/no-bindingでは同じメソッドからBindingResultを外し、同じ「二個」と「0」を送ると400・MethodArgumentNotValidExceptionになりました。画面へエラーを返す修正では、単に期待値を200へ変えず、BindingResultの位置・エラー分岐・fieldError・Service未実行まで揃えて確認します。公式のModelAttribute説明でも、直後のBindingResultの有無で扱いが分かれます。

fieldErrorは確認できたのに画面にメッセージが出ない場合は、バリデーションメッセージが表示されない原因で、th:errorsとForm名の対応を確認してください。

JSONの400は、変換とValidationを切り分ける

JSON側は@RestControllerのメソッドで@Valid @RequestBodyを受け、BindingResultや独自の例外ハンドラーは付けていません。DTOとメソッドの抜粋は次のとおりです。Serviceのcreate(OrderRequest request)はvoidで、成功時には受け取った内容をJSONで返す検証用APIです。

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public record OrderRequest(@NotNull Long customerId, @NotNull @Min(1) Integer quantity) {}
@PostMapping("/api/orders")
public ResponseEntity<OrderRequest> create(@Valid @RequestBody OrderRequest request) {
    orderService.create(request);
    return ResponseEntity.ok(request);
}
ORDER権限と有効CSRFを付けた入力結果・Resolved Exception修正
{broken-json}/quantityが"二個"400・HttpMessageNotReadableExceptionJSON構文・型を直す
quantityが0/quantityを省略400・MethodArgumentNotValidExceptionMin・NotNullの制約を満たす
.paramだけでJSON本文なし400・HttpMessageNotReadableException.contentへ本文を渡す
正常なJSONでもContent-Typeがtext/plain415・HttpMediaTypeNotSupportedExceptionapplication/jsonにする

修正後の正常系では、ObjectMapperで本文を作り、200だけでなくJSONの値とServiceの呼出しを確認します。Content-Typeの誤りはこの構成では415なので、400と決めつけて処理しないでください。

@Test
void validJsonReturns200AndCallsService() throws Exception {
    OrderRequest request = new OrderRequest(10L, 2);
    var result = mockMvc.perform(post("/api/orders")
                    .with(user("operator").roles("ORDER")).with(csrf())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.customerId").value(10))
            .andExpect(jsonPath("$.quantity").value(2)).andReturn();
    verify(orderService).create(request);
}

JSONの変換後に@Validが失敗する場合の標準動作は、公式のRequestBody説明でも400とされています。URL・RequestParam・PathVariableなど、別の入力方法で400になる場合はControllerに値が入らない時の400確認順へ進んでください。

andDo(print())で失敗した層と修正後を確認する

出力ではStatusだけでなく、HandlerとResolved Exceptionを確認します。SecurityがFilterで止めた今回の403では両方ともnullでした。Resolved Exceptionがnullでも、リクエストが成功したという意味ではありません。

また、不正JSONへCSRFを付けずに送ると、JSON変換まで進まず403でした。認証・権限・CSRFを揃えた後で400に変わったら、次は入力変換を直す段階です。次のテストは不正JSONの400を観測してから、同じ権限とCSRFで本文を直し、200まで確認します。

@Test
void correctedJsonAfter400ReachesService() throws Exception {
    var failed = mockMvc.perform(post("/api/orders")
                    .with(user("operator").roles("ORDER")).with(csrf())
                    .contentType(MediaType.APPLICATION_JSON).content("{broken-json}"))
            .andDo(print()).andExpect(status().isBadRequest()).andReturn();
    assertThat(failed.getResolvedException()).isInstanceOf(HttpMessageNotReadableException.class);
    verifyNoInteractions(orderService);

    var fixed = mockMvc.perform(post("/api/orders")
                    .with(user("operator").roles("ORDER")).with(csrf())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(new OrderRequest(10L, 2))))
            .andExpect(status().isOk()).andExpect(jsonPath("$.quantity").value(2)).andReturn();
    verify(orderService).create(new OrderRequest(10L, 2));
}

例外型のassertは今回の切り分け用です。アプリの継続テストでは、利用者へ返すステータス・エラー項目・画面遷移を契約として残し、内部例外だけに依存しないようにします。Serviceまで処理が届いた後の重複チェックなどは、ControllerとServiceの入力チェックの役割で整理できます。

403・400の確認順

  1. テストが読み込むSecurityFilterChainと、認証方式・必要権限を確認する
  2. 403なら正しい権限のユーザーでCSRFなし/ありを比較する
  3. CSRFありでも失敗したら、未認証と権限不足を分ける
  4. 400ならHandler・Resolved Exceptionと入力値を確認する
  5. FormはBindingResultの有無、画面・fieldError・Service未実行を確認する
  6. JSONはContent-Type・本文・型・Validationを確認する
  7. 入力や前提を修正し、成功時の遷移・本文・Service呼出しまで確かめる

現場レビューでよくある指摘

// レビューコメント例
403を回避するためテスト用Security設定でCSRFを無効化しています。
本番と同じ保護を確認するならwith(csrf())を使ってください。

// レビューコメント例
@RequestBodyのControllerに.paramだけを渡しています。
application/jsonのContent-TypeとJSON本文を設定してください。

// レビューコメント例
期待値を400へ変更して通していますが、本来は登録成功のテストです。
andDo(print())でResolved Exceptionを確認し、入力値を修正してください。

提出前のセルフチェック

  • 403と400で確認順を分けたか
  • CSRFを無効化せずテスト用トークンを付けたか
  • 必要な認証ユーザーと権限を用意したか
  • Formには.param、JSONには.contentを使ったか
  • JSONへapplication/jsonを設定したか
  • Formの200を登録成功とせず、fieldErrorとService未実行を確認したか
  • andDo(print())でHandlerと例外を確認したか
  • 案件バージョンに合うBeanモック用アノテーションを使ったか

壊れにくいテスト設計を学ぶ参考書

MockMvcのAPIだけでなく、何をControllerテストの契約として固定するかを学ぶと、実装変更に強いテストを書きやすくなります。

書籍「単体テストの考え方/使い方」の表紙
商品画像:Amazon.co.jp
PR テストを学ぶ若手向け

単体テストの考え方/使い方

Vladimir Khorikov (著), 須田智之 (翻訳)

単体テストで「何を検証するか」の判断基準を学ぶ。

モックの使いどころや壊れにくいテストの考え方を整理できます。コード例はC#です。JUnitの操作入門ではなく、テストの設計と価値を深める本です。

  • テスト対象の切り分けに迷っている
  • 保守しやすい単体テストを書きたい

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

この記事とあわせて読みたい

関連記事Spring Bootでバリデーションメッセージが表示されない|Thymeleafの原因と修正 fieldErrorはあるのに画面へ出ない場合に、th:errorsとForm名を確認します。 記事を読む 関連記事Spring Bootで400 Bad Requestになる原因|Controllerに値が入らない時の確認順 RequestParam・PathVariable・JSONのどこで変換が止まるかを切り分けます。 記事を読む 関連記事Spring Bootで入力チェックが重複する|ControllerとServiceの分け方 画面の入力形式と業務上の制約をController・Serviceへ分けて整理します。 記事を読む

まとめ

MockMvcのPOSTは、まずSecurityの前提を揃えます。今回の構成ではCSRF不足・権限不足は403、CSRFありの未認証は401でした。FormエラーはBindingResultありなら200で画面へ戻り、なしなら400になります。

Formはparam、JSONはContent-Typeとcontentを使い分けます。失敗を観測し、原因に対応する条件だけを直し、正常時の遷移・JSON・Service呼出しまで確かめると、期待値を合わせただけのテストを防げます。

MockMvcのリクエストが通った後は、利用者へ返すエラー文や入力値の保持も確認します。Spring Bootのフォーム入力・バリデーション実践ガイドから、画面側の検証記事へ進めます。

関連記事Spring Bootのフォーム入力・バリデーション実践ガイド|実装からテストまで フォームを作って終わりにしない。入力の約束からエラー時の再表示、Serviceの業務判断、MockMvcでの確認まで、既存記事を読む順と確認する問いをまとめました。 記事を読む

-設計・テスト・レビュー
-,