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

Spring

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

AIによる要約

MockMvcのPOSTが403なら、Controllerへ届く前にSpring Securityが拒否している可能性が高く、認証・権限・CSRFを確認します。CSRF保護が有効ならテストリクエストへ.with(csrf())を付けます。400なら、Formのparam名・型・Validation、またはJSONのContent-Type・構文・項目型を確認します。ステータスを無理に変更せず、andDo(print())とresolved exceptionで失敗した層を特定します。

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

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

一方、失敗時に「ControllerテストだからControllerだけが原因」と考えると遠回りします。403と400では止まっている層が違うため、ステータスから確認順を変えます。

この記事のポイント

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

403と400は原因の層が違う

ステータス主な原因Controllerへ到達
403 ForbiddenCSRF、権限、Security設定到達前のことが多い
400 Bad Request必須値、型変換、JSON、Validation本体実行前のことが多い
404 Not FoundURL、Mapping到達しない
405 Method Not AllowedGET・POSTの違い到達しない
500 Internal Server ErrorServiceや処理中の例外到達後の場合がある

期待値をisForbidden()へ変えてテストを通すのではなく、仕様上成功すべきリクエストなら、なぜSecurityや入力変換で拒否されたかを調べます。

POSTが403になる代表原因はCSRF

@Test
void 注文を登録できる() throws Exception {
    mockMvc.perform(post("/orders")
                    .param("customerId", "10")
                    .param("quantity", "2"))
            .andExpect(status().is3xxRedirection());
}

Spring SecurityでCSRF保護が有効な場合、POST、PUT、PATCH、DELETEなど状態を変更するリクエストにはCSRFトークンが必要です。ブラウザのFormにはThymeleafなどがトークンを入れていても、MockMvcのテストでは明示的に付ける必要があります。

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;

mockMvc.perform(post("/orders")
                .with(csrf())
                .param("customerId", "10")
                .param("quantity", "2"))
        .andExpect(status().is3xxRedirection());

.with(csrf())はSecurityのテスト支援機能で、有効なCSRFトークンをリクエストへ追加します。本番設定のCSRFを無効にしてテストだけ通す方法ではありません。

403対策としてCSRFを無条件にdisableしない

ブラウザのCookie認証を使う画面ではCSRF対策が必要です。APIの認証方式などによって無効化する設計もありますが、テスト失敗を消すためではなく、アプリ全体のセキュリティ方針に基づいて判断します。

認証・権限不足でも403になる

@Test
@WithMockUser(
        username = "operator",
        roles = "ORDER")
void 注文担当者は登録できる() throws Exception {
    mockMvc.perform(post("/orders")
                    .with(csrf())
                    .param("customerId", "10")
                    .param("quantity", "2"))
            .andExpect(status().is3xxRedirection());
}

認証が必要なURLでは、テストにも利用者情報を用意します。@WithMockUserのroleと、Security設定のhasRolehasAuthorityを混同しないようにします。

未認証時に401になるかログイン画面へリダイレクトされるか、認証済みだが権限不足なら403になるかは、アプリの設定で異なります。期待するセキュリティ仕様をテスト名で示します。

FormのPOSTが400になる原因

public class OrderForm {
    @NotNull
    private Long customerId;

    @NotNull
    @Min(1)
    private Integer quantity;
}

@PostMapping("/orders")
public String create(
        @Valid @ModelAttribute("orderForm") OrderForm form,
        BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        return "orders/input";
    }
    orderService.create(form);
    return "redirect:/orders";
}

Form送信では.paramの名前がFormプロパティと一致するか確認します。値は文字列として送られ、SpringがLongやIntegerへ変換します。

// 正常
mockMvc.perform(post("/orders")
        .with(csrf())
        .param("customerId", "10")
        .param("quantity", "2"));

// 型変換できず400になり得る
mockMvc.perform(post("/orders")
        .with(csrf())
        .param("customerId", "ABC")
        .param("quantity", "二個"));

ValidationエラーをBindingResultで受けて同じ画面へ戻す設計なら、空欄の結果は400ではなく200になることもあります。「入力エラーは常に400」と決めつけず、HTML画面とJSON APIで期待値を分けます。

JSONのPOSTが400になる原因

@PostMapping("/api/orders")
public ResponseEntity<OrderResponse> create(
        @Valid @RequestBody OrderRequest request) {
    return ResponseEntity.ok(orderService.create(request));
}
String json = """
        {"customerId":10,"quantity":2}
        """;

mockMvc.perform(post("/api/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(json))
        .andExpect(status().isOk());

JSONテストでは、Content-Typeと本文が必要です。Form用の.paramだけを書いても、@RequestBodyへJSONは入りません。

  • JSONのカンマ・引用符が正しいか
  • Content-Typeがapplication/jsonか
  • 項目名がDTOと一致するか
  • 数値・日付・Enumへ変換できるか
  • @NotNullなどの制約に違反していないか

JSONを手書きすると構文ミスが混ざるため、正常系はObjectMapperで生成するとDTOとの対応が分かりやすくなります。不正JSONのテストでは、あえて壊した文字列を使います。

OrderRequest request = new OrderRequest(10L, 2);

mockMvc.perform(post("/api/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
        .andExpect(status().isOk());

andDo(print())で実際の結果を見る

mockMvc.perform(post("/api/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(json))
        .andDo(print())
        .andExpect(status().isOk());

出力では、リクエストURL、パラメータ、ヘッダー、Handler、Resolved Exception、レスポンス本文を確認できます。期待値だけを何度も書き換えるより、解決された例外名を見る方が原因へ近づけます。

MvcResult result = mockMvc.perform(post("/api/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content("{broken-json}"))
        .andReturn();

Exception exception = result.getResolvedException();
assertNotNull(exception);

調査用のassertを最終テストへ残すかは目的次第です。通常はステータス、エラーコード、フィールドを利用者向け契約として検証し、内部例外クラスへ強く依存しすぎないようにします。

@WebMvcTestでServiceを置き換える

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private OrderService orderService;
}

@WebMvcTestは主にWeb層を読み込み、Serviceなどをモックへ置き換えてControllerの契約を確認します。現行Spring Frameworkでは@MockitoBeanを使える構成がありますが、古いSpring Boot案件では@MockBeanが使われています。記事のコードだけを貼らず、案件のバージョンと既存テストへ揃えてください。

Security設定を除外してテストを通す方法もありますが、実運用で保護されるURLなら、主要テストではSecurityを含めて認証・権限・CSRFも確認する価値があります。

403・400の確認順

  1. andDo(print())で実際のステータスとHandlerを見る
  2. 403なら認証、権限、CSRFを確認する
  3. POSTへwith(csrf())が必要な設定か確認する
  4. 400ならFormかJSONかを確認する
  5. Formならparam名・型・Validationを確認する
  6. JSONならContent-Type・構文・項目型を確認する
  7. Resolved Exceptionとサーバー側エラー形式を確認する
  8. 正常系と拒否される条件を別テストにする

403はSecurity、400は入力変換という入口から調べると、Controllerの実装を無関係に変更せずに済みます。

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

// レビューコメント例
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を設定したか
  • Validationエラー時の画面・API仕様を確認したか
  • andDo(print())でHandlerと例外を確認したか
  • 案件バージョンに合うBeanモック用アノテーションを使ったか

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

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

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

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

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

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

モックの使いどころや壊れにくいテストの考え方を整理できます。JUnitの構文を覚えた次に読む本です。

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

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

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

まとめ

MockMvcのPOSTが403なら認証・権限・CSRF、400ならForm・JSON・Validationを確認します。CSRF保護が有効なリクエストには.with(csrf())を付け、本番設定を無条件に無効化しません。

Formはparam、JSONはContent-Typeとcontentを使い分け、andDo(print())でHandlerとResolved Exceptionを確認します。期待値だけを書き換えず、どの層で拒否されたかを説明できるテストにしましょう。

-Spring
-, , , , ,