Spring Security를 사용하는 애플리케이션에서 테스트를 작성할 때, 인증된 사용자 컨텍스트를 사용해야 하는 경우가 있습니다. Spring Security는 이를 위해 다음과 같은 어노테이션을 지원합니다.
- `@WithMockUser`
- `@WithUserDetails`
- `@WithSecurityContext`
@WithMockUser
`@WithMockUser`는
Spring Security 테스트를 위한 가장 기본적인 어노테이션입니다.
@Test
@WithMockUser
public void testSecuredMethod() {
// 테스트 코드
}
특징
- 실제 사용자 데이터 없이도 테스트가 가능합니다.
- 기본적으로 필드는 다음과 같이 설정됩니다.
- `username` : "user"
- `password` : "password"
- `role` : "ROLE_USER"
다음과 같이, username, password, roles을 커스터마이징할 수 있습니다.
@Test
@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
public void testAdminAccess() {
// 테스트 코드
}
- 결과적으로 `UsernamePasswordAuthenticationToken` 타입의 Authentication 객체가 SecurityContext에 생성됩니다.
실제로, `@WithMockUser`의 코드를 살펴보면 다음과 같이 `WithMockUserSecurityContextFactory` 클래스를 사용하고 있는 것을 확인할 수 있습니다.
해당 클래스에서 SecurityContext를 생성해 반환하는 것을 확인할 수 있습니다.
주의사항
- `@WithMockUser`는 Spring Security의 User 객체를 사용합니다(위 사진에서 밑줄 친 부분의 `User`). 따라서, UserDetails를 직접 구현하는 Custom User 객체를 사용하는 경우, 이를 사용할 수 없습니다.
따라서, `@WithMockUser` 어노테이션은 간단한 인증 시나리오 테스트에는 적합하지만, 복잡한 인증 로직이나 커스텀 UserDetails를 사용하는 경우에는 사용에 한계가 존재합니다.
@WithUserDetails
`@WithUserDetails`는
Spring Security 테스트에서 실제 `UserDetailsService`를 사용해 인증된 사용자 컨텍스트를 시뮬레이션하는 어노테이션입니다.
따라서, `@WithMockUser`보다 더 실제 환경에 가까운 테스트가 가능합니다.
@Test
@WithUserDetails("testuser@example.com")
public void testUserAccess() {
// 테스트 코드
}
특징
- 실제 `UserDetailsSerivce` 구현체를 사용해 `UserDetails` 객체를 로드합니다.
- DB에 저장된 실제 사용자 정보로 테스트가 가능합니다.
- 기본적으로 "user"라는 이름의 사용자를 찾습니다.
- 사용자 이름을 직접 지정할 수 있고, 필요한 경우 사용할 `UserDetailsService` Bean의 이름도 지정할 수 있습니다.
@Test
@WithUserDetails(value = "admin@example.com", userDetailsServiceBeanName = "customUserDetailsService")
public void testAdminAccess() {
// 테스트 코드
}
실제, 구현된 코드를 확인해보면 다음과 같습니다.
마찬가지로 `WithSecurityContextFactory`를 구현한 `WithUserDetailsSecurityContextFactory`를 사용하고 있는 것을 확인할 수 있습니다.
`WithUserDetailsSecurityContextFactory`의 `createSecurityContext` 메서드를 확인해보면 위와 같습니다. 내부 로직에서 실제로 `UserDetailsService`와 이를 통해 `UserDetails` 객체를 가져오는 것을 확인할 수 있습니다.
주의사항
- `UserDetailsService` 구현이 필요합니다.
- 테스트용 사용자 데이터가 미리 준비되어 있어야 합니다.
- 테스트 실행 전에 지정된 사용자가 DB에 존재해야 합니다.
결국, 실제 DB에 저장된 사용자의 데이터를 사용하는 방식이기 때문에, 여러 가지로 고려해야 할 점이 많은 테스트 방식입니다.
@WithSecurityContext
`@WithSecurityContext`는 가장 유연한 방식으로,
직접 SecurityContext를 생성할 수 있게 해주는 어노테이션입니다.
특징
- 실제 `UserDetails` 객체를 사용할 수 있습니다.
- 완전한 커스터마이징이 가능합니다.
- 복잡한 인증 시나리오를 테스트할 수 있습니다.
- 실제 애플리케이션의 보안 로직을 정확하게 테스트할 수 있습니다.
쉽게 말해, 위의 `@WithMockUser`와 `@WithUserDetails` 어노테이션이 사용한 `WithSecurityContextFactory`를 직접 구현하는 방식입니다.
구현 순서
구현에 들어가기 앞서, 현재 애플리케이션에서 구현된 사용자 관련 부분의 코드는 다음과 같습니다.
User.class
@Entity
@Getter
@NoArgsConstructor
@Table(name = "users")
public class User extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String email;
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRoleEnum role;
public User(String username, String password, String email, UserRoleEnum role) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
}
}
UserDetailsImpl.class
@RequiredArgsConstructor
public class UserDetailsImpl implements UserDetails {
private final User user;
public User getUser() {
return user;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
UserRoleEnum role = user.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
}
UserDetailsService.class
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + username));
return new UserDetailsImpl(user);
}
}
`@WithSecurityContext`를 사용하기 위한 과정은 다음과 같습니다.
1. 커스텀 어노테이션 생성
애플리케이션에 구현된 사용자 엔티티의 형식에 맞게 테스트에서 사용할 Mock 사용자 어노테이션을 생성합니다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithSecurityContextFactoryImpl.class)
public @interface MockUser {
long id() default 1L;
String username() default "mockUser";
String password() default "password";
String email() default "mock@email.com";
UserRoleEnum role() default UserRoleEnum.USER;
}
2. SecurityContextFactory 구현
위에서 생성한 커스텀 어노테이션이 사용할 SecurityContextFactory를 구현합니다.
public class WithSecurityContextFactoryImpl implements WithSecurityContextFactory<MockUser> {
@Override
public SecurityContext createSecurityContext(MockUser annotation) {
User mockUser = new User(annotation.username(), annotation.password(), annotation.email(), annotation.role());
mockUser.setId(annotation.id());
UserDetailsImpl userDetails = new UserDetailsImpl(mockUser);
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
userDetails, userDetails.getPassword(), userDetails.getAuthorities()
);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(token);
return context;
}
}
위 코드에 보이는 것처럼, `@WithMockUser`, `@WithUserDetails`와는 다르게 직접 구현한 `UserDetailsImpl`를 사용해 SecurityContext를 만들어 반환하는 것을 확인할 수 있습니다.
3. 테스트에 어노테이션 적용
이제 만들어진 어노테이션을 Spring Security 테스트에 활용할 수 있습니다.
@Test
@MockUser
void testWithMockCustomUser() throws Exception {
// Given
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetailsImpl userDetails = (UserDetailsImpl)authentication.getPrincipal();
User user = userDetails.getUser();
...
}
이처럼, `@WithSecurityContext`를 사용하면 테스트 코드를 더욱 유연하고 강력하게 만들 수 있으며, 실제 애플리케이션의 보안 로직을 정확하게 테스트할 수 있습니다.