Spring Securityを使用したアプリケーションでは、認証・認可機能が正しく動作することを保証するテストが不可欠です。本記事では、spring-security-testモジュールを活用したセキュリティテストの実装方法を解説します。@WithMockUserなどのアノテーションによるユーザーモック、SecurityMockMvcRequestPostProcessorsによるCSRF/JWT/OAuth2テスト、そして実践的なテストパターンまで網羅的に紹介します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Security 6.4.x
JUnit 5.x
ビルドツール Maven または Gradle

事前に以下の知識があると理解がスムーズです。

  • Spring Securityの基本概念(認証・認可の違い)
  • MockMvcを使ったコントローラテストの基礎
  • JUnit 5の基本的な使い方

spring-security-testの導入

Spring Securityのテストサポートを利用するには、spring-security-test依存関係を追加します。

Mavenの場合

1
2
3
4
5
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

Gradleの場合

1
testImplementation 'org.springframework.security:spring-security-test'

spring-boot-starter-testと組み合わせることで、MockMvc、JUnit 5、AssertJなど必要なテストライブラリが揃います。

1
2
3
4
5
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

テスト対象のサンプルアプリケーション

本記事では、以下のシンプルなユーザー管理APIを例にテストコードを解説します。

SecurityConfig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/users/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults())
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            );
        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user = User.withUsername("user")
            .password("{noop}password")
            .roles("USER")
            .build();
        UserDetails admin = User.withUsername("admin")
            .password("{noop}admin")
            .roles("ADMIN")
            .build();
        return new InMemoryUserDetailsManager(user, admin);
    }
}

UserController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public List<User> getAllUsers() {
        return userService.findAll();
    }

    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.findById(id);
    }

    @PostMapping
    public User createUser(@RequestBody User user) {
        return userService.create(user);
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public void deleteUser(@PathVariable Long id) {
        userService.delete(id);
    }

    @GetMapping("/me")
    public String getCurrentUser(Principal principal) {
        return "Hello, " + principal.getName();
    }
}

MockMvcとSpring Securityの統合セットアップ

Spring SecurityとMockMvcを統合するには、SecurityMockMvcConfigurers.springSecurity()を適用します。

@WebMvcTestを使用する場合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;

@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    // テストメソッド
}

@WebMvcTestを使用する場合、@ImportでSecurityConfigを明示的にロードする必要があります。

WebApplicationContextを使用する場合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;

@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {SecurityConfig.class, UserController.class})
@WebAppConfiguration
class UserControllerManualSetupTest {

    @Autowired
    private WebApplicationContext context;

    private MockMvc mockMvc;

    @BeforeEach
    void setup() {
        mockMvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())
            .build();
    }
}

springSecurity()を適用することで、Spring SecurityのFilterChainProxyがMockMvcに統合され、認証・認可のテストが可能になります。

@WithMockUserによるユーザーモック

@WithMockUserは、テスト実行時に仮想的なユーザーでSecurityContextを設定する最も簡単な方法です。実際のユーザーが存在する必要はありません。

基本的な使用方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class WithMockUserBasicTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    @WithMockUser
    void デフォルトユーザーでアクセス可能() throws Exception {
        when(userService.findAll()).thenReturn(List.of());

        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk());
    }
}

@WithMockUserのデフォルト設定は以下の通りです。

属性 デフォルト値
username user
password password
roles ROLE_USER

ユーザー名とロールのカスタマイズ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Test
@WithMockUser(username = "testuser", roles = {"USER", "ADMIN"})
void カスタムユーザーでアクセス() throws Exception {
    mockMvc.perform(get("/api/admin/settings"))
        .andExpect(status().isOk());
}

@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void 管理者ロールで削除可能() throws Exception {
    mockMvc.perform(delete("/api/users/1")
            .with(csrf()))
        .andExpect(status().isOk());
}

authoritiesの使用

roles属性は自動的にROLE_プレフィックスを付加します。プレフィックスなしの権限を指定する場合はauthoritiesを使用します。

1
2
3
4
5
6
@Test
@WithMockUser(username = "user", authorities = {"USER_READ", "USER_WRITE"})
void カスタム権限でアクセス() throws Exception {
    mockMvc.perform(get("/api/users"))
        .andExpect(status().isOk());
}

クラスレベルでの適用

テストクラス全体で同じユーザーを使用する場合、クラスレベルにアノテーションを付与できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
@WithMockUser(username = "admin", roles = "ADMIN")
class AdminUserTests {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    void 管理者機能にアクセス可能() throws Exception {
        mockMvc.perform(get("/api/admin/dashboard"))
            .andExpect(status().isOk());
    }

    @Test
    void ユーザー一覧を取得可能() throws Exception {
        when(userService.findAll()).thenReturn(List.of());

        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk());
    }
}

@WithAnonymousUserによる匿名ユーザーテスト

@WithAnonymousUserは、未認証(匿名)ユーザーとしてテストを実行します。クラスレベルで@WithMockUserを設定している場合に、特定のテストのみ匿名ユーザーで実行したい場合に便利です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
@WithMockUser
class AnonymousUserTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    void 認証済みユーザーはアクセス可能() throws Exception {
        when(userService.findAll()).thenReturn(List.of());

        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk());
    }

    @Test
    @WithAnonymousUser
    void 匿名ユーザーは認証エラー() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithAnonymousUser
    void 公開エンドポイントは匿名でもアクセス可能() throws Exception {
        mockMvc.perform(get("/api/public/info"))
            .andExpect(status().isOk());
    }
}

@WithUserDetailsによる実ユーザーの利用

@WithMockUserは仮想ユーザーを使用しますが、@WithUserDetailsは実際のUserDetailsServiceからユーザーを取得します。カスタムUserDetails実装を使用している場合に有効です。

基本的な使用方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@SpringBootTest
@AutoConfigureMockMvc
class WithUserDetailsTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithUserDetails("user")
    void 実ユーザーでアクセス() throws Exception {
        mockMvc.perform(get("/api/users/me"))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("user")));
    }

    @Test
    @WithUserDetails(value = "admin", userDetailsServiceBeanName = "myUserDetailsService")
    void 特定のUserDetailsServiceを使用() throws Exception {
        mockMvc.perform(get("/api/admin/settings"))
            .andExpect(status().isOk());
    }
}

カスタムUserDetailsの実装例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class CustomUserDetails implements UserDetails {

    private final Long id;
    private final String username;
    private final String password;
    private final Collection<? extends GrantedAuthority> authorities;

    public CustomUserDetails(Long id, String username, String password, 
                             Collection<? extends GrantedAuthority> authorities) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.authorities = authorities;
    }

    public Long getId() {
        return id;
    }

    // UserDetailsインターフェースのメソッド実装
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }
}

@WithSecurityContextによるカスタム認証

@WithMockUser@WithUserDetailsでは対応できない複雑な認証シナリオでは、@WithSecurityContextを使用してカスタムアノテーションを作成できます。

カスタムアノテーションの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {

    String username() default "testuser";

    long id() default 1L;

    String[] roles() default {"USER"};
}

SecurityContextFactoryの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class WithMockCustomUserSecurityContextFactory
        implements WithSecurityContextFactory<WithMockCustomUser> {

    @Override
    public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();

        List<GrantedAuthority> authorities = Arrays.stream(annotation.roles())
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
            .collect(Collectors.toList());

        CustomUserDetails principal = new CustomUserDetails(
            annotation.id(),
            annotation.username(),
            "password",
            authorities
        );

        Authentication auth = UsernamePasswordAuthenticationToken.authenticated(
            principal,
            "password",
            authorities
        );

        context.setAuthentication(auth);
        return context;
    }
}

カスタムアノテーションの使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class CustomUserAnnotationTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    @WithMockCustomUser(username = "customuser", id = 100L, roles = {"USER", "ADMIN"})
    void カスタムユーザーでテスト() throws Exception {
        mockMvc.perform(get("/api/users/me"))
            .andExpect(status().isOk());
    }
}

SecurityMockMvcRequestPostProcessorsの活用

SecurityMockMvcRequestPostProcessorsは、リクエスト単位でセキュリティコンテキストを設定するユーティリティを提供します。

静的インポート

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

user()によるユーザー指定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Test
void user_postProcessorでユーザー指定() throws Exception {
    mockMvc.perform(get("/api/users")
            .with(user("testuser").roles("USER")))
        .andExpect(status().isOk());
}

@Test
void 複数ロールを持つユーザー() throws Exception {
    mockMvc.perform(get("/api/admin/settings")
            .with(user("admin").password("pass").roles("USER", "ADMIN")))
        .andExpect(status().isOk());
}

@Test
void カスタムUserDetailsを使用() throws Exception {
    CustomUserDetails userDetails = new CustomUserDetails(
        1L, "user", "password", 
        List.of(new SimpleGrantedAuthority("ROLE_USER"))
    );

    mockMvc.perform(get("/api/users/me")
            .with(user(userDetails)))
        .andExpect(status().isOk());
}

anonymous()による匿名アクセス

1
2
3
4
5
6
@Test
void 匿名ユーザーとしてリクエスト() throws Exception {
    mockMvc.perform(get("/api/users")
            .with(anonymous()))
        .andExpect(status().isUnauthorized());
}

authentication()によるカスタム認証

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Test
void カスタムAuthenticationを使用() throws Exception {
    Authentication auth = new UsernamePasswordAuthenticationToken(
        "user", "password", 
        List.of(new SimpleGrantedAuthority("ROLE_USER"))
    );

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

CSRFトークンのテスト

CSRF保護が有効な場合、POST/PUT/DELETE/PATCHリクエストには有効なCSRFトークンが必要です。

有効なCSRFトークンの付与

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Test
@WithMockUser
void CSRFトークン付きでPOSTリクエスト() throws Exception {
    User newUser = new User(null, "newuser", "new@example.com");
    when(userService.create(any())).thenReturn(new User(1L, "newuser", "new@example.com"));

    mockMvc.perform(post("/api/users")
            .with(csrf())
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                {"name": "newuser", "email": "new@example.com"}
                """))
        .andExpect(status().isOk());
}

CSRFトークンをヘッダーで送信

1
2
3
4
5
6
7
8
@Test
@WithMockUser
void CSRFトークンをヘッダーで送信() throws Exception {
    mockMvc.perform(delete("/api/users/1")
            .with(csrf().asHeader())
            .with(user("admin").roles("ADMIN")))
        .andExpect(status().isOk());
}

無効なCSRFトークンのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Test
@WithMockUser
void 無効なCSRFトークンで403エラー() throws Exception {
    mockMvc.perform(post("/api/users")
            .with(csrf().useInvalidToken())
            .contentType(MediaType.APPLICATION_JSON)
            .content("{}"))
        .andExpect(status().isForbidden());
}

@Test
@WithMockUser
void CSRFトークンなしで403エラー() throws Exception {
    mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{}"))
        .andExpect(status().isForbidden());
}

HTTP Basic認証のテスト

httpBasic()を使用して、HTTP Basic認証のテストを簡潔に記述できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Test
void HTTP_Basic認証でアクセス() throws Exception {
    mockMvc.perform(get("/api/users")
            .with(httpBasic("user", "password")))
        .andExpect(status().isOk());
}

@Test
void 不正な認証情報で401エラー() throws Exception {
    mockMvc.perform(get("/api/users")
            .with(httpBasic("user", "wrongpassword")))
        .andExpect(status().isUnauthorized());
}

JWT認証のテスト

OAuth2リソースサーバーを使用している場合、jwt()ポストプロセッサーでJWT認証をモックできます。

基本的なJWTテスト

1
2
3
4
5
6
@Test
void JWTトークンでアクセス() throws Exception {
    mockMvc.perform(get("/api/users")
            .with(jwt()))
        .andExpect(status().isOk());
}

デフォルトのJWTは以下の特性を持ちます。

1
2
3
4
5
6
7
{
  "headers": { "alg": "none" },
  "claims": {
    "sub": "user",
    "scope": "read"
  }
}

JWTクレームのカスタマイズ

1
2
3
4
5
6
7
8
9
@Test
void カスタムクレームを持つJWT() throws Exception {
    mockMvc.perform(get("/api/users")
            .with(jwt().jwt(jwt -> jwt
                .claim("sub", "admin")
                .claim("scope", "read write")
                .claim("user_id", "12345"))))
        .andExpect(status().isOk());
}

JWT権限のカスタマイズ

1
2
3
4
5
6
7
8
@Test
void カスタム権限を持つJWT() throws Exception {
    mockMvc.perform(get("/api/admin/settings")
            .with(jwt().authorities(
                new SimpleGrantedAuthority("SCOPE_admin"),
                new SimpleGrantedAuthority("ROLE_ADMIN"))))
        .andExpect(status().isOk());
}

完全なJwtオブジェクトの指定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Test
void 完全なJwtオブジェクトを使用() throws Exception {
    Jwt jwt = Jwt.withTokenValue("token")
        .header("alg", "RS256")
        .claim("sub", "admin")
        .claim("scope", "read write admin")
        .claim("iss", "https://auth.example.com")
        .build();

    mockMvc.perform(get("/api/admin/settings")
            .with(jwt().jwt(jwt)))
        .andExpect(status().isOk());
}

OAuth2ログインのテスト

OAuth2/OIDC認証を使用している場合、oidcLogin()oauth2Login()でテストできます。

OIDCログインのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Test
void OIDCログインユーザーでアクセス() throws Exception {
    mockMvc.perform(get("/api/users/me")
            .with(oidcLogin()))
        .andExpect(status().isOk());
}

@Test
void カスタムクレームを持つOIDCユーザー() throws Exception {
    mockMvc.perform(get("/api/users/me")
            .with(oidcLogin()
                .idToken(token -> token
                    .claim("sub", "user123")
                    .claim("email", "user@example.com")
                    .claim("name", "Test User"))))
        .andExpect(status().isOk());
}

@Test
void カスタム権限を持つOIDCユーザー() throws Exception {
    mockMvc.perform(get("/api/admin/settings")
            .with(oidcLogin()
                .authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
        .andExpect(status().isOk());
}

OAuth2ログインのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Test
void OAuth2ログインユーザーでアクセス() throws Exception {
    mockMvc.perform(get("/api/users/me")
            .with(oauth2Login()))
        .andExpect(status().isOk());
}

@Test
void カスタム属性を持つOAuth2ユーザー() throws Exception {
    mockMvc.perform(get("/api/users/me")
            .with(oauth2Login()
                .attributes(attrs -> attrs.put("user_id", "12345"))))
        .andExpect(status().isOk());
}

メソッドセキュリティのテスト

@PreAuthorize@PostAuthorizeなどのメソッドセキュリティもテスト可能です。

サービス層のテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Service
public class SecuredService {

    @PreAuthorize("hasRole('ADMIN')")
    public void adminOnlyMethod() {
        // 管理者のみ実行可能
    }

    @PreAuthorize("#username == authentication.name")
    public String getUserData(String username) {
        return "Data for " + username;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@SpringBootTest
class MethodSecurityTest {

    @Autowired
    private SecuredService securedService;

    @Test
    @WithMockUser(roles = "ADMIN")
    void 管理者はadminOnlyMethodを実行可能() {
        assertDoesNotThrow(() -> securedService.adminOnlyMethod());
    }

    @Test
    @WithMockUser(roles = "USER")
    void 一般ユーザーはadminOnlyMethodを実行不可() {
        assertThrows(AccessDeniedException.class, 
            () -> securedService.adminOnlyMethod());
    }

    @Test
    @WithMockUser(username = "john")
    void 自分のデータのみ取得可能() {
        String result = securedService.getUserData("john");
        assertThat(result).isEqualTo("Data for john");
    }

    @Test
    @WithMockUser(username = "john")
    void 他人のデータは取得不可() {
        assertThrows(AccessDeniedException.class, 
            () -> securedService.getUserData("jane"));
    }
}

認証・認可テストパターン集

実践的なテストシナリオをパターン別に紹介します。

認証テストパターン

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class AuthenticationTestPatterns {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    void 未認証ユーザーは保護リソースにアクセス不可() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser
    void 認証済みユーザーはアクセス可能() throws Exception {
        when(userService.findAll()).thenReturn(List.of());

        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk());
    }

    @Test
    void 公開エンドポイントは認証不要() throws Exception {
        mockMvc.perform(get("/api/public/health"))
            .andExpect(status().isOk());
    }
}

認可テストパターン

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class AuthorizationTestPatterns {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    @WithMockUser(roles = "USER")
    void USERロールは一般機能にアクセス可能() throws Exception {
        when(userService.findAll()).thenReturn(List.of());

        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(roles = "USER")
    void USERロールは管理機能にアクセス不可() throws Exception {
        mockMvc.perform(get("/api/admin/settings"))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void ADMINロールは管理機能にアクセス可能() throws Exception {
        mockMvc.perform(get("/api/admin/settings"))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(roles = "USER")
    void USERロールは削除操作不可() throws Exception {
        mockMvc.perform(delete("/api/users/1")
                .with(csrf()))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void ADMINロールは削除操作可能() throws Exception {
        mockMvc.perform(delete("/api/users/1")
                .with(csrf()))
            .andExpect(status().isOk());
    }
}

エラーレスポンスの検証

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class SecurityErrorResponseTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    void 認証エラー時のレスポンス検証() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isUnauthorized())
            .andExpect(header().exists("WWW-Authenticate"));
    }

    @Test
    @WithMockUser(roles = "USER")
    void 認可エラー時のレスポンス検証() throws Exception {
        mockMvc.perform(get("/api/admin/settings"))
            .andExpect(status().isForbidden());
    }
}

テストメタアノテーションの活用

同じユーザー設定を複数のテストで使用する場合、メタアノテーションを定義すると便利です。

メタアノテーションの定義

1
2
3
4
5
6
7
8
9
@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
public @interface WithMockAdmin {
}

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "user", roles = "USER")
public @interface WithMockStandardUser {
}

メタアノテーションの使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class MetaAnnotationTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    @WithMockAdmin
    void 管理者テスト() throws Exception {
        mockMvc.perform(delete("/api/users/1").with(csrf()))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockStandardUser
    void 一般ユーザーテスト() throws Exception {
        when(userService.findAll()).thenReturn(List.of());

        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk());
    }
}

テスト実装のベストプラクティス

Spring Securityのテストを効果的に実装するためのベストプラクティスを紹介します。

テストの構造化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Nested
    @DisplayName("認証テスト")
    class AuthenticationTests {

        @Test
        @DisplayName("未認証ユーザーは401を返す")
        void unauthenticated_returns401() throws Exception {
            mockMvc.perform(get("/api/users"))
                .andExpect(status().isUnauthorized());
        }

        @Test
        @WithMockUser
        @DisplayName("認証済みユーザーは200を返す")
        void authenticated_returns200() throws Exception {
            when(userService.findAll()).thenReturn(List.of());

            mockMvc.perform(get("/api/users"))
                .andExpect(status().isOk());
        }
    }

    @Nested
    @DisplayName("認可テスト")
    class AuthorizationTests {

        @Test
        @WithMockUser(roles = "USER")
        @DisplayName("USERロールは管理機能にアクセス不可")
        void userRole_cannotAccessAdmin() throws Exception {
            mockMvc.perform(get("/api/admin/settings"))
                .andExpect(status().isForbidden());
        }

        @Test
        @WithMockUser(roles = "ADMIN")
        @DisplayName("ADMINロールは管理機能にアクセス可能")
        void adminRole_canAccessAdmin() throws Exception {
            mockMvc.perform(get("/api/admin/settings"))
                .andExpect(status().isOk());
        }
    }
}

セキュリティテストの網羅性チェックリスト

以下の観点でテストを網羅することを推奨します。

カテゴリ テスト項目
認証 未認証アクセスの拒否
認証 認証済みアクセスの許可
認証 不正な認証情報の拒否
認可 ロールベースのアクセス制御
認可 権限ベースのアクセス制御
認可 リソース所有者の検証
CSRF CSRFトークンの検証
CSRF 無効なCSRFトークンの拒否
メソッドセキュリティ @PreAuthorizeの動作確認
メソッドセキュリティ @PostAuthorizeの動作確認

まとめ

本記事では、Spring Securityのテスト実装について解説しました。主要なポイントは以下の通りです。

  1. spring-security-testモジュールを導入することで、セキュリティテストに必要なユーティリティが利用可能になる
  2. @WithMockUserは最も簡単にユーザーをモックする方法であり、ユーザー名・ロール・権限をカスタマイズできる
  3. @WithUserDetailsは実際のUserDetailsServiceからユーザーを取得するため、カスタムUserDetails実装のテストに有効
  4. @WithSecurityContextを使用すれば、完全にカスタマイズされた認証コンテキストを作成できる
  5. SecurityMockMvcRequestPostProcessorsは、リクエスト単位でCSRF、JWT、OAuth2などの認証情報を付与できる
  6. メタアノテーションを活用することで、テストコードの重複を削減し、可読性を向上できる

セキュリティテストは、アプリケーションの安全性を担保する上で不可欠な要素です。本記事で紹介したテスト手法を活用し、堅牢なセキュリティ機能を実装してください。

参考リンク