요약
@WithMockUser는 기본적인 인증만 제공한다. 하지만 우리는 도메인에 맞는 커스텀 UserDetails를 사용하는 상황이고, 이럴 경우 @WithMockUser만으로는 인증 객체의 테스트가 어렵다. 이 글에서는 그런 상황에서 커스텀 인증 어노테이션을 만들어 중복 없이 테스트하는 방법을 정리한다.
문제
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public UserRes getUser(@AuthenticationPrincipal UserDetails userDetails) {
return userService.getUser(userDetails.getUsername());
}
}
getUser 메서드처럼 @AuthenticationPrincipal UserDetails userDetails를 받는 컨트롤러 메서드가 있다.
만약 컨트롤러 메서드에서 UserDetails 인터페이스만 받는다면, @WithMockUser만으로도 충분하다.
하지만 UserDetailsImpl처럼 커스텀 인증 객체를 사용하고, 이 객체 안의 추가 필드나 로직을 테스트하고 싶다면 @WithMockUser로는 부족하다.
해결
나는 따로 @WithMockUser와 비슷한 역할을 하는 커스텀 어노테이션을 만들어주었다.
사전 코드 설명
우선 User, UserDetailsImpl 코드이다.
@Getter
@Entity
@Table(name = "tb_user")
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String password;
private String role;
@CreatedDate
private LocalDateTime createdAt;
public User(String name, String password) {
this.name = name;
this.password = password;
this.role = "ROLE_USER";
}
}
public record UserDetailsImpl(User user) implements UserDetails {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(user::getRole);
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getName();
}
// 이후 생략
}
정말 간단하게 작성했다. 사실 이 정도면 @WithMockUser 써도 된다 ㅋ
테스트 코드를 보여주기 전에, 컨트롤러 슬라이스 테스트를 위한 밑작업을 해두었다.
public interface UserTest {
String TEST_USER_NAME = "name01";
String TEST_USER_PASSWORD = "1234";
String TEST_USER_ROLE = "ROLE_USER";
LocalDateTime TEST_USER_CREATED_AT = LocalDateTime.now();
User TEST_USER = new User(TEST_USER_NAME, TEST_USER_PASSWORD);
}
나는 테스트용 인터페이스를 따로 만들어두는 편이다.
이렇게 만들어두고, 테스트 객체에서는 인터페이스를 구현하여 사용한다. 클래스가 아닌 인터페이스인 이유는, 여러 도메인을 implements 받을 수 있어서이다. 인터페이스의 본연의 역할과는 다르게 사용하긴 하지만.. 아직까지 더 나은 방식을 못 찾아서 일단 쓰는 중,,
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
public abstract class BaseControllerTest {
@Autowired
protected WebApplicationContext context;
protected MockMvc mockMvc;
@BeforeEach
void setUpMockMvc() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
}
그리고 컨트롤러 테스트용으로 공통 추상 클래스를 만들어서 웹 컨텍스트와 MockMvc를 상속하여 재사용하도록 했다.
사전 작업 끝!
컨트롤러 테스트 코드 - 변경 전
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// ...
@WebMvcTest(controllers = {UserController.class})
class UserControllerTest extends BaseControllerTest implements UserTest {
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private UserService userService;
@BeforeEach
void setUp() {
UserDetails userDetails = new UserDetailsImpl(TEST_USER);
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
securityContext.setAuthentication(auth);
}
@Test
@DisplayName("유저 조회 테스트")
void get_authenticated_user_test() throws Exception {
// given
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
UserRes res = new UserRes(userDetails.getUsername(), TEST_USER_ROLE);
given(userService.getUser(anyString())).willReturn(res);
// when
ResultActions perform = mockMvc.perform(get("/users")
.contentType(MediaType.APPLICATION_JSON)
);
// then
perform.andExpectAll(status().isOk(),
jsonPath("$.name").value(res.name()),
jsonPath("$.role").value(res.role())
);
}
}
이 코드는 컨트롤러 슬라이스 테스트다.
@WebMvcTest는 컨트롤러 슬라이스 테스트를 할 수 있도록 도와주는 어노테이션이다. 전체 빈을 로딩하지 않고, 컨트롤러 관련 빈만 불러오기 때문에 테스트 속도가 빠르다. 그리고 컨트롤러도 테스트 대상인 UserController 만을 대상으로 지정했다.
@BeforeEach
void setUp() {
UserDetails userDetails = new UserDetailsImpl(TEST_USER);
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
securityContext.setAuthentication(auth);
}
이 부분을 보면
- UserDetails 생성
- SecurityContext 생성
- UserDetails 기반으로 Authentication 객체 생성
- SecurityContext 내 인증 처리
로 인증 처리를 해주고 있다.
하지만 지금은 이 컨트롤러 하나지만, 나중에 컨트롤러가 늘어날 때마다 중복 코드가 만들어지게 된다. 그래서 이러한 중복을 없애기 위한 방법이 있다. 바로 @WithMockUser를 쓰는 것.
@WebMvcTest(controllers = {UserController.class})
@WithMockUser(username = "hi") // 여기 !!!
class UserControllerTest extends BaseControllerTest implements UserTest {
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private UserService userService;
// setUp 제거 !!!
@Test
@DisplayName("유저 조회 테스트")
void get_authenticated_user_test() throws Exception {
// given
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
UserRes res = new UserRes(userDetails.getUsername(), TEST_USER_ROLE);
given(userService.getUser(anyString())).willReturn(res);
// when
ResultActions perform = mockMvc.perform(get("/users")
.contentType(MediaType.APPLICATION_JSON)
);
// then
perform.andExpectAll(status().isOk(),
jsonPath("$.name").value(res.name()),
jsonPath("$.role").value(res.role())
);
}
}
실제로 이렇게 해도 테스트는 통과한다.
@Test
@DisplayName("유저 조회 테스트")
void get_authenticated_user_test() throws Exception {
UserDetailsImpl userDetails = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Assertions.assertThat(userDetails.user().getCreatedAt()).isNull();
}
하지만 이렇게 커스텀 UserDetails의 필드를 조회해야할 때는 사용이 불가능하다. 또 컨트롤러에서 커스텀 UserDetails를 받을 때도.
그래서 커스텀 어노테이션을 만들었다.
커스텀 어노테이션 작성
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import org.springframework.security.test.context.support.WithSecurityContext;
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String name() default UserTest.TEST_USER_NAME;
String password() default UserTest.TEST_USER_PASSWORD;
String role() default UserTest.TEST_USER_ROLE;
}
우선 어노테이션을 만든다. 나는 @WithMockCustomUser이라고 만들었다.
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.test.context.support.WithSecurityContextFactory;
public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomUser>, UserTest {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
User user = new User(customUser.name(), customUser.password());
UserDetails userDetails = new UserDetailsImpl(user);
Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
return context;
}
}
그리고 SecurityContextFactory를 커스텀으로 만들어주면 된다. 내부 로직은 위와 비슷하게, customUser로부터 User를 만들어서 인증 처리한다.
package org.springframework.security.test.context.support;
import java.lang.annotation.Annotation;
import org.springframework.security.core.context.SecurityContext;
public interface WithSecurityContextFactory<A extends Annotation> {
SecurityContext createSecurityContext(A annotation);
}
WithSecurityContextFactory<A>에서 A 제네릭은 앞서 만든 WithMockCustomUser 어노테이션을 넣어주면 된다. 어노테이션밖에 못 들어간다.
컨트롤러 테스트 코드 - 변경 후
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// ...
@WebMvcTest(controllers = {UserController.class})
@WithMockCustomUser(name = "hi") // 여기 1 !!!
class UserControllerTest extends BaseControllerTest implements UserTest {
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private UserService userService;
@Test
@DisplayName("유저 조회 테스트")
void get_authenticated_user_test() throws Exception {
// given
// 여기 2 !!!
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
UserRes res = new UserRes(userDetails.getUsername(), TEST_USER_ROLE, TEST_USER_CREATED_AT);
given(userService.getUser(anyString())).willReturn(res);
// when
ResultActions perform = mockMvc.perform(get("/users").contentType(MediaType.APPLICATION_JSON));
// then
perform.andExpectAll(status().isOk(),
jsonPath("$.name").value(res.name()),
jsonPath("$.role").value(res.role()),
jsonPath("$.createdAt").value(res.createdAt().toString())
);
}
}
그 다음 아까 봤었던 컨트롤러에 적용해 보았다.
컨트롤러 클래스에 @WithMockCustomUser(name = "hi") 어노테이션이 붙었다. 이를 통해 커스텀 UserDetails에 접근할 수 있다.
'TIL' 카테고리의 다른 글
| TIL #132 : Arrays.fill의 얕은 복사로 인한 참조 공유 이슈 (0) | 2025.06.14 |
|---|---|
| TIL #131 : Spring Kafka에서 @KafkaListener 기본 컨테이너 팩토리 설정 오류 해결 (1) | 2025.06.09 |
| TIL #129 : 구글 번역 → next-intl 전환으로 렌더링 속도 70% 개선 (2.34s → 0.71s) (0) | 2025.04.08 |
| TIL #128 : PNG 이미지를 WebP 확장자로 변환하여 95% 용량 절감 (0) | 2025.04.07 |
| TIL #127 : Axios Interceptor 도입으로 인증 공통화 및 141줄 절감 (0) | 2025.04.07 |