지난 포스팅에서는 인가 로직에서 @PreAuthorize를 사용하도록 리팩토링했습니다.
이번에는 @PreAuthorize를 적용한 메서드를 어떻게 테스트했는지 정리해보도록 하겠습니다.
@PreAuthorize에서 이용하고 있는 EntityAccessHandler에서는,
현재 로그인한 사용자의 userId와 엔티티의 소유자의 userId를 비교하는 로직이 있습니다.
그리고 현재 로그인한 사용자의 userId를 가져오기 위해서는
SecurityContextHolder의 SecurityContext의 Authentication의 Principal을 이용합니다.
따라서 SecurityContext에 사용자의 인증 정보를 주입하여 테스트를 진행해야겠다고 생각했습니다.
그 방법으로 SecurityContext를 직접 mocking할 지,
@WithMockUser, @WithUserDetails, @WithSecurityContext와 같은 어노테이션을 사용할 지 고민이 됐습니다.
직접 해보자 싶어 SecurityContext를 mocking하는 방식으로 먼저 테스트를 진행해보았는데, 전체적으로 코드가 복잡해졌고 중복 코드도 많이 발생했습니다.
따라서 이번에는 어노테이션을 이용해 좀 더 간결하게 통합 테스트를 진행해보려고 합니다.
또한 인증 로직에서 UserDetails를 구현하지 않고 Authentication 객체를 커스텀하여 사용하고 있기 때문에,
테스트할 때 @WithMockUser, @WithUserDetails가 아닌 @WithSecurityContext를 사용했습니다.
@WithSecurityContext는 커스텀 SecurityContext를 설정할 때 사용하는 어노테이션입니다.
일종의 factory를 만들어 보안 컨텍스트를 설정하는 방식으로 구현합니다.
우선 테스트에서 활용할 커스텀 어노테이션을 만들어 줘야 합니다.
저는 WithTestUser라는 이름으로 만들었습니다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithTestUserSecurityContextFactory.class)
public @interface WithTestUser {
}
우선 이렇게만 만들어주고, 어노테이션을 처리해줄 WithSecurityContextFactory를 만들겠습니다.
이 클래스를 구현하기 위해서는 WithSecurityContextFactory<A extends Annotation> 인터페이스를 implements 해줘야 합니다.
그 후 아까 만든 어노테이션을 파라미터로 받는 createSecurityContext 를 오버라이딩해야 합니다.
public class WithTestUserSecurityContextFactory implements WithSecurityContextFactory<WithTestUser> {
@Override
public SecurityContext createSecurityContext(WithTestUser annotation) {
return null;
}
}
이 메서드의 자세한 처리는 @WithMockUser의 팩토리 클래스인 WithMockUserSecurityContextFactory를 좀 참고했는데,
결국 실제로 동작하는 것과 비슷하게 SecurityContext에 Authentication 객체를 세팅해주면 될 것 같았습니다.
Authentication 객체를 만들 때 userId (principal)와 token (credientials)이 필요한데,
이건 테스트할 때 어노테이션에서 전달해주면 좋을 것 같아 위에서 만든 어노테이션 클래스에 속성을 추가해줬습니다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithTestUserSecurityContextFactory.class)
public @interface WithTestUser {
long userId() default 0L;
String token() default "";
}
왜 어노테이션 속성에는 long을 써야하는가?
long
Primitive타입이기 때문에 컴파일 타임에 고정된 값을 저장할 수 있음 → 허용Long
Wrapper class, 객체이므로 런타임에 생성됨 → 허용하지 않음String
- 불변 객체이기 때문에 컴파일 타임에 고정된 값을 저장할 수 있음 → 허용
결과적으로 팩토리 클래스는 아래와 같이 만들어줬습니다.
public class WithTestUserSecurityContextFactory implements WithSecurityContextFactory<WithTestUser> {
@Override
public SecurityContext createSecurityContext(WithTestUser annotation) {
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
Long userId = annotation.userId();
String token = annotation.token();
Authentication authentication = AuthenticationToken.getAuthentication(userId, token);
securityContext.setAuthentication(authentication);
return securityContext;
}
}
이제 테스트 코드에 적용해보겠습니다.
테스트 대상은 아래의 getUserInfo로, 사용자의 정보를 가져오는 간단한 기능이며 자기 자신의 정보만 요청할 수 있기 때문에 @PreAuthorize를 이용했습니다.
@PreAuthorize("@userAccessHandler.isOwner(#userId)")
public UserDetailResponse getUserInfo(Long userId) {
User user = getUserById(userId);
return UserDetailResponse.of(user);
}
SecurityContext에 사용자 정보 주입을 해준 후 테스트를 하는 것이기 때문에
테스트 클래스에 @SpringBootTest 어노테이션을 붙여 전체 애플리케이션 컨텍스트를 로드했습니다.
코드는 아래와 같이 작성했습니다.
@SpringBootTest
public class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@MockBean
private UserRepository userRepository;
.
.
.
@Test
@WithTestUser(userId = 1L, token = "TOKEN")
@DisplayName("사용자 정보 조회 실패_다른 사용자의 정보를 요청할 경우")
void getUserInfo_otherUser_throwAuthorizationDeniedException() {
//given
Long userId = 2L;
//when & then
assertThrows(AuthorizationDeniedException.class, () -> userService.getUserInfo(userId));
}
1번 사용자로 로그인했을 때 2번 사용자에 대해 정보 조회를 요청할 경우 예외가 발생하는지 검증합니다.

SecurityContext를 직접 mocking했을 때보다 훨씬 코드가 깔끔해져 가독성이 좋아졌습니다 🧤🧼
당분간은 테스트 코드 관련 포스팅이 많을 것 같습니다.