real-world프로젝트 이슈 - @AuthenticationPrincipal 테스트코드 작성하기
개요
real-world프로젝트를 진행하다보니 회원유저, 비회원유저 식별 필터를 제작하였다.
본 서비스를 제작하는데에는 여러 예제코드들이 있어서 큰 어려움이 없었다.
@AuthenticationPrincipal
어노테이션을 붙여서 본 서비스를 제작하고 실제 잘 통신하는걸 확인하였다.
1
2
3
4
@GetMapping
public UserResponse currentUser(@AuthenticationPrincipal UserAuth userAuth) {
return userService.getCurrentUser(userAuth);
}
문제는.. 이거 단위 테스트 어떻게 작성하지? 였다.
그냥 일단 작성해보았다.
1
2
3
4
5
6
7
8
9
10
UserResponse userResponse = UserResponse.builder()
.username(userAuth.getUsername())
.email(userAuth.getEmail())
.image(userAuth.getImage())
.bio(userAuth.getBio()).build();
when(userService.getCurrentUser(any(UserAuth.class))).thenReturn(userResponse);
mockMvc.perform(get("/api/user")
.with(csrf())
).andExpect(status().isOk())
.andExpect(jsonPath("$.user.email", Matchers.equalTo(userResponse.getEmail())))
당연히 될리가 없다.
그러면 직접 SecurityContext에 인증정보를 넣어줘야 한다.
1
2
3
4
5
6
UserAuth userAuth = UserAuth.builder()
.username("kms")
.email("test@gmail.com")
.build();
Authentication auth = new UsernamePasswordAuthenticationToken(userAuth, "", null);
SecurityContextHolder.getContext().setAuthentication(auth);
내 코드기준 SecurityContext에 인증정보를 넣는 방법은 다음과 같았다. 테스트 코드내에 삽입해주었다.
성공하였다.
하지만 저 코드를 매 번 삽입해줘야하나?
아니면 @BeforeEach
로 테스트 수행마다 넣어줘야할까? 인증이 필요없는 메소드에도 적용이 될 것이고, 알 수 없는 에러를 발생시킬 수 있다.
공식문서에서는 이를 테스트하기 위한 방법 여러방법 중 하나로 @WithSecurityContext
를 제공하여 인증정보를 커스텀할 수 있게 도와준다고 한다.
나는 이 방법을 토대로 해결하였으나 궁금해서 나머지도 찾아보았다.
위처럼 SecurityContextHolder
객체에 직접 넣어주는것과 @WithMockUser
를 사용하는 것이 있다.
다음은 @WithMockUser
는 정말 간단한 유저인증 정보를 컨텍스트에 넣을 수 있다.
1
@WithMockUser(username = "kms")
이러한 내용을 테스트메소드위에 달아서 사용할 수는 있으나, 이 프로젝트와 같이 인증정보를 객체로 넣은 경우에는 커스텀이 불가능하다.
무슨 말이냐하면
1
2
Authentication auth = new UsernamePasswordAuthenticationToken(userAuth, "", null);
SecurityContextHolder.getContext().setAuthentication(auth);
처럼 userAuth라는 인스턴스를 인증객체로 생성해서 컨텍스트에 세팅하는데 @WithMockUser
는 이처럼 인스턴스 자체를 컨텍스트에 넣는게 불가능 하다는 것이다. 즉 @WithMockUser(userAuth= 어떤 객체)
같은 코드는 불가능 하다.
참고로 @WithUserDetails
어노테이션도 있는데, 이는 UserDetailsService
라는 인터페이스를 구현한 메서드로 컨텍스트에 인증정보를 넣었을 때 사용이 가능하다.
위 프로젝트는 UserDetailsService
를 구현하긴 하지만 이 인터페이스의 메소드인 loadUserByUsername()
의 반환값으로 컨텍스트에 인증정보를 넣지 않는다.
@WithMockUser
보다는 커스텀하는데에는 가능성이 있음을 보였다.
하지만 이 또한 유연하게 대처가 불가능하다. 테스트코드를 위해 기존 로직을 손대야함의 불편함이 생겨 서로에게 의존적이게 되기 때문이다.
즉, 테스트코드는 인증에 대해서 실제 서비스 코드와 완벽히 분리되어야 한다.
그래서 결국 적절한 값으로 커스텀마이징 해주려면 새로운 인증 테스트 방식이 필요하다.
✔ @WithSecurityContext
이 방식을 통해서 테스트 코드를 좀 더 유연하게 작성이 가능해진다.
먼저, 자신이 설정하고 싶은 이름으로 어노테이션을 작성해야한다.
1
2
3
4
5
6
7
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithAuthUserSecurityContextFactory.class)
public @interface WithAuthUser {
long id() default 1L;
String email() default "test@gmail.com";
String username() default "kms";
}
프로젝트에서 인증을 하기 위한 최소한 조건인 컬럼들을 넣어두었다. id, email, username이다.
그리고 이를 관리하는 클래스를 만들어 준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class WithAuthUserSecurityContextFactory implements WithSecurityContextFactory<WithAuthUser> {
@Override
public SecurityContext createSecurityContext(WithAuthUser annotation) {
Long id = annotation.id();
String email = annotation.email();
String username = annotation.username();
UserAuth userAuth = UserAuth.builder()
.email(email)
.username(username)
.id(id)
.build();
Authentication authentication = new UsernamePasswordAuthenticationToken(userAuth,"",null);
SecurityContext context = SecurityContextHolder.getContext();
context.setAuthentication(authentication);
return context;
}
}
공식 문서에 의하면 @WithSecurityContext(factory = WithAuthUserSecurityContextFactory.class)
이 구분은 다음과 같은 의미를 담고 있다고 한다.
SecurityContextRepository
라는 인증정보 컨텍스트를 관리하는 인터페이스에다가 테스트용 인증정보를 생성할 의도가 있음을 알린다.- 이 의미를 보고
SecurityContextRepository
는 컨텍스트를 생성할 때WithSecurityContextFactory
의 구현체인 WithAuthUser (위 코드 기준)어노테이션에 지정된 속성 값들로 인증정보를 생성하고 컨텍스트에 담는다.
그래서 위 코드를 보면 커스텀 어노테이션을 구현체로 설정하고 어노테이션에 지정된 값들을 뽑아내, UserAuth
객체를 만들고 이를 컨텍스트에 넣어주는 코드임을 알 수 있다.
이러한 결과로 WithSecurityContextTestExecutionListener
라는 스프링 시큐리티에서 테스트 수행시 인증정보를 생성할 수 있게 해주는 실행 리스너가 SecurityContext에 우리가 커스텀한 값으로 채워질 수 있게 한다.
공식문서에서는 추가로 UserDetailsService
을 의존성 주입을 통해 가져다 쓸 수 있다고 하지만 이 프로젝트에서는 쓰이지 않았으므로 알아두고 넘어갔다.
결국 테스트코드는 다음과 같이 작성하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@WithAuthUser(email = "test@gmail.com",username = "kms",id = 1L)
@Test
@DisplayName("현재유저 찾기 컨트롤러 테스트")
void currentUserSuccess() throws Exception {
UserResponse userResponse = UserResponse.builder()
.username("kms")
.email("test@gmail.com")
.build();
when(userService.getCurrentUser(any(UserAuth.class))).thenReturn(userResponse);
mockMvc.perform(get("/api/user")
).andExpect(status().isOk())
.andExpect(jsonPath("$.user.email", Matchers.equalTo(userResponse.getEmail())))
.andExpect(jsonPath("$.user.bio", Matchers.equalTo(userResponse.getBio())))
.andExpect(jsonPath("$.user.username", Matchers.equalTo(userResponse.getUsername())))
.andExpect(jsonPath("$.user.image", Matchers.equalTo(userResponse.getImage())));
}
커스텀 어노테이션의 속성값을 따로 지정해줄 수 있다는 사실.
성공이다.
복기하자면, 커스텀 어노테이션에 인증에 필요한 값들을 넣고 SecurityContextRepository
인터페이스에 커스텀 어노테이션을 구현체로 넣어 구현한 클래스에서, SecurityContext에 인증정보를 객체의 형태로 넣을 수 있게 되었다.