[Security/Test] : @WithMockUser, @WithUserDetails, @WithSecurityContext를 활용한 Spring Security Test 작성

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 클래스를 사용하고 있는 것을 확인할 수 있습니다.

etc-image-0
etc-image-1

해당 클래스에서 SecurityContext를 생성해 반환하는 것을 확인할 수 있습니다.

 

주의사항

  • @WithMockUserSpring 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() {
    // 테스트 코드
}

 

실제, 구현된 코드를 확인해보면 다음과 같습니다. 

etc-image-2

마찬가지로 WithSecurityContextFactory를 구현한 WithUserDetailsSecurityContextFactory를 사용하고 있는 것을 확인할 수 있습니다.

etc-image-3
etc-image-4

WithUserDetailsSecurityContextFactorycreateSecurityContext 메서드를 확인해보면 위와 같습니다. 내부 로직에서 실제로 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를 사용하면 테스트 코드를 더욱 유연하고 강력하게 만들 수 있으며, 실제 애플리케이션의 보안 로직을 정확하게 테스트할 수 있습니다.