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


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+正常Form | 302 → /orders | Serviceが1回呼ばれ、302と遷移先URLを確認(GET先画面は対象外) |
| ORDER+CSRF+Formの型・入力エラー、BindingResultあり | 200+fieldError | 入力画面へ戻る。登録成功ではない |
| 同じFormエラー、BindingResultなし | 400 | 引数解決時のエラーをControllerで受けていない |
| ORDER+CSRF+不正JSON/必須・範囲違反 | 400 | JSON変換またはValidationを修正 |
| ORDER+CSRF+JSON、Content-Typeがtext/plain | 415 | application/jsonへ修正 |
| ORDER+CSRF+正常JSON | 200+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・HttpMessageNotReadableException | JSON構文・型を直す |
| quantityが0/quantityを省略 | 400・MethodArgumentNotValidException | Min・NotNullの制約を満たす |
.paramだけでJSON本文なし | 400・HttpMessageNotReadableException | .contentへ本文を渡す |
| 正常なJSONでもContent-Typeがtext/plain | 415・HttpMediaTypeNotSupportedException | application/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の確認順
- テストが読み込むSecurityFilterChainと、認証方式・必要権限を確認する
- 403なら正しい権限のユーザーでCSRFなし/ありを比較する
- CSRFありでも失敗したら、未認証と権限不足を分ける
- 400ならHandler・Resolved Exceptionと入力値を確認する
- FormはBindingResultの有無、画面・fieldError・Service未実行を確認する
- JSONはContent-Type・本文・型・Validationを確認する
- 入力や前提を修正し、成功時の遷移・本文・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テストの契約として固定するかを学ぶと、実装変更に強いテストを書きやすくなります。
単体テストの考え方/使い方
単体テストで「何を検証するか」の判断基準を学ぶ。
モックの使いどころや壊れにくいテストの考え方を整理できます。コード例はC#です。JUnitの操作入門ではなく、テストの設計と価値を深める本です。
- テスト対象の切り分けに迷っている
- 保守しやすい単体テストを書きたい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
MockMvcのPOSTは、まずSecurityの前提を揃えます。今回の構成ではCSRF不足・権限不足は403、CSRFありの未認証は401でした。FormエラーはBindingResultありなら200で画面へ戻り、なしなら400になります。
Formはparam、JSONはContent-Typeとcontentを使い分けます。失敗を観測し、原因に対応する条件だけを直し、正常時の遷移・JSON・Service呼出しまで確かめると、期待値を合わせただけのテストを防げます。
MockMvcのリクエストが通った後は、利用者へ返すエラー文や入力値の保持も確認します。Spring Bootのフォーム入力・バリデーション実践ガイドから、画面側の検証記事へ進めます。




