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


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 Forbidden | CSRF、権限、Security設定 | 到達前のことが多い |
| 400 Bad Request | 必須値、型変換、JSON、Validation | 本体実行前のことが多い |
| 404 Not Found | URL、Mapping | 到達しない |
| 405 Method Not Allowed | GET・POSTの違い | 到達しない |
| 500 Internal Server Error | Serviceや処理中の例外 | 到達後の場合がある |
期待値を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設定のhasRole・hasAuthorityを混同しないようにします。
未認証時に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の確認順
- andDo(print())で実際のステータスとHandlerを見る
- 403なら認証、権限、CSRFを確認する
- POSTへwith(csrf())が必要な設定か確認する
- 400ならFormかJSONかを確認する
- Formならparam名・型・Validationを確認する
- JSONならContent-Type・構文・項目型を確認する
- Resolved Exceptionとサーバー側エラー形式を確認する
- 正常系と拒否される条件を別テストにする
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テストの契約として固定するかを学ぶと、実装変更に強いテストを書きやすくなります。
単体テストの考え方/使い方
単体テストで「何を検証するか」の判断基準を学ぶ。
モックの使いどころや壊れにくいテストの考え方を整理できます。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を確認します。期待値だけを書き換えず、どの層で拒否されたかを説明できるテストにしましょう。
