テスト対象のUserControllerにSpring Securityを入れた途端、@WebMvcTestのテストが軒並み401や403で落ちるようになった経験はありませんか。MockMvcの基本的な使い方は分かっていても、認証済み状態の作り方は意外と情報が散らばっていて迷いますよね。

この記事では、@WithMockUser・@WithUserDetails・SecurityMockMvcRequestPostProcessorsを使い分けながら、認証必須のController テストを安定して通す方法をまとめます。

なぜ@WebMvcTestで401/403になるのか

spring-security-testとspring-boot-starter-securityがクラスパスにあると、@WebMvcTestはセキュリティ自動構成ごとController層のコンテキストを起動します。つまりテストにも本番と同じフィルタチェーンがかかり、authorizeHttpRequestsの設定に従って未認証リクエストは401(または302)で弾かれます。

@MockBean(もしくは@MockitoBean)でServiceをモック化しても、これは認証とは別レイヤーの話なので解決しません。「Serviceはモックできたのに401になる」と混乱したら、まずセキュリティ側の問題だと切り分けましょう。

@WithMockUserで認証済みユーザーを再現する

一番手軽なのが@WithMockUserです。テストメソッド(またはクラス)に付けるだけで、SecurityContextにモックのAuthenticationがセットされた状態でリクエストが実行されます。

@Test
@WithMockUser
void 認証済みなら200を返す() throws Exception {
    mockMvc.perform(get("/api/users/me"))
        .andExpect(status().isOk());
}

デフォルトのusernameは"user"、ロールは"ROLE_USER"です。ユーザー名やロールを変えたいときは属性を指定します。

@Test
@WithMockUser(username = "admin", roles = {"ADMIN", "USER"})
void 管理者は管理画面にアクセスできる() throws Exception {
    mockMvc.perform(get("/admin/dashboard"))
        .andExpect(status().isOk());
}

ここで注意したいのが roles() と authorities() の違いです。roles("ADMIN") は自動的に "ROLE_ADMIN" として扱われますが、authorities("ADMIN") はそのまま "ADMIN" になります。

@Test
@WithMockUser(authorities = "SCOPE_read")
void authoritiesはROLE_プレフィックスを付与しない() throws Exception {
    mockMvc.perform(get("/api/reports"))
        .andExpect(status().isOk());
}

hasRole("ADMIN") は内部で "ROLE_ADMIN" に変換して比較するので、roles() と組み合わせるのが自然です。一方 hasAuthority("SCOPE_read") のように任意の権限文字列を直接比較したい場合は authorities() を使います。ここを混同すると「ロールは合っているはずなのに403になる」というハマりどころにつながるので、SecurityConfig側の hasRole / hasAuthority の指定とセットで確認してください。

@WithUserDetailsとの違い

@WithMockUserはテスト内で完結したモックのユーザーを作りますが、実際のUserDetailsService実装からロードしたプリンシパルで検証したい場合は@WithUserDetailsが向いています。

@Test
@WithUserDetails(value = "[email protected]", userDetailsServiceBeanName = "userDetailsService")
void 実際のUserDetailsServiceからロードしたユーザーで検証する() throws Exception {
    mockMvc.perform(get("/api/users/me"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.email").value("[email protected]"));
}

認可情報がDBやドメインオブジェクト側で定義されていて、カスタムUserDetails実装のフィールド(社員IDやテナントIDなど)までテストで検証したい場合はこちらを選びましょう。逆に「とにかく認証済み状態を作りたいだけ」なら@WithMockUserで十分です。

SecurityMockMvcRequestPostProcessorsで柔軟に認証を注入する

アノテーションはテストメソッド単位でしか切り替えられません。同じテストクラス内でリクエストごとに異なる認証状態を試したいときは、SecurityMockMvcRequestPostProcessorsのuser()やauthentication()を.with()に渡す方法が便利です。

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

@Test
void リクエスト単位でユーザーを切り替える() throws Exception {
    mockMvc.perform(get("/api/orders/1").with(user("hanako").roles("USER")))
        .andExpect(status().isOk());

    mockMvc.perform(get("/api/orders/1").with(user("admin").roles("ADMIN")))
        .andExpect(status().isOk());
}

任意のAuthenticationオブジェクトをそのまま渡したい場合はauthentication()を使います。

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

Authentication auth = new UsernamePasswordAuthenticationToken(
    "taro", null, List.of(new SimpleGrantedAuthority("ROLE_USER")));

mockMvc.perform(get("/api/orders/1").with(authentication(auth)))
    .andExpect(status().isOk());

OAuth2リソースサーバー構成でJWTを使っている場合は、同じ仕組みでjwt()postprocessorが用意されています。JWT特有の設定は別記事の範囲になるので、ここでは存在だけ触れておきます。

POST/PUT/DELETEとCSRFトークン

CSRF保護が有効な状態では、GET以外のリクエストにCSRFトークンが付いていないと403が返ります。認証は通っているのにPOSTだけ403になる場合、これが原因であることが多いです。

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

@Test
@WithMockUser
void CSRFトークン付きなら注文を作成できる() throws Exception {
    mockMvc.perform(post("/api/orders")
            .with(csrf())
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"itemId\": 1, \"quantity\": 2}"))
        .andExpect(status().isCreated());
}

@Test
@WithMockUser
void CSRFトークンが無いと403になる() throws Exception {
    mockMvc.perform(post("/api/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{}"))
        .andExpect(status().isForbidden());
}

APIが完全にステートレス(トークン認証のみ)でCSRF保護自体を無効化している設計なら.with(csrf())は不要です。ただしテスト側だけCSRFを無効化した構成で書いてしまうと本番のフィルタ設定と乖離するので、実際のSecurityConfigのcsrf()設定に合わせてテストを書くようにしましょう。

@PreAuthorizeを@WebMvcTestで検証する

@PreAuthorizeによるメソッドセキュリティは@EnableMethodSecurityが有効なコンテキストでなければ動作しません。@WebMvcTestはController周りのBeanに絞ってコンテキストを構成するため、SecurityConfigをスキャン対象に含めていないと@PreAuthorizeが素通りしてしまうことがあります。

@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerSecurityTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    OrderService orderService;

    @Test
    @WithMockUser(roles = "USER")
    void USERロールでは削除できず403になる() throws Exception {
        mockMvc.perform(delete("/api/orders/1").with(csrf()))
            .andExpect(status().isForbidden());
    }
}

@Import(SecurityConfig.class)で認可設定を明示的に取り込むことで、@PreAuthorizeの判定までスライステストの範囲でカバーできます。もっと複雑な認可ロジック(SpELで所有者チェックをしているなど)まで踏み込むなら、@SpringBootTestでの統合テストに切り出すことも検討してください。

まとめ

認証必須のControllerテストは、状況に応じて手段を選べば迷わなくなります。

  • とにかく認証済み状態を再現したいだけなら@WithMockUser
  • 実際のUserDetailsService実装に依存した検証が必要なら@WithUserDetails
  • リクエストごとに認証情報を切り替えたいならSecurityMockMvcRequestPostProcessorsのuser()/authentication()
  • POST/PUT/DELETEでは.with(csrf())の要否を必ず確認する
  • @PreAuthorizeまで検証したいなら@Import(SecurityConfig.class)を忘れずに

@WebMvcTest自体の基本や@MockBeanの使い方はControllerテストの基本ガイド、テスト全般の考え方はJUnitとMockitoを使ったテスト入門も参考にしてください。

関連記事