스프링 시큐리티를 통해서 유저 인증을 하고 시큐리티 컨텍스트에서 로그인 된 유저의 정보를 받아와서 처리하는 로직이 많이 있었다.
그동안은 직접 JWT를 넣어서 직접 테스트를 진행했었다.
매번 @SpringBootTest를 사용해서 테스트를 하자고 하니 테스트 속도가 너무 오래걸렸다. 그래서 오늘은 Spring Security를 활용해서 단위 테스트를 하면서 알게 된 내용들에 대해 정리하고자 한다.
Spring에서 단위 테스트를 할 때, 테스트 위에 붙혀줘야하는 어노테이션이 있다.
배경 지식
Junit 버전이 5 미만인 경우
@RunWith(SpringRunner.class) 또는 @RunWith(MockitoJUnitRunner.class) 등을 사용해야 한다.
Junit 버전이 5 이상인 경우
@ExtendWith(SpringExtension.class) 또는 @ExtendWith(MockitoExtension.class) 등을 사용해야 한다.
@RunWith vs @ExtendWith
JUnit 4는 추가 기능을 적용하여 테스트를 실행하는 맞춤 Runner를 @RunWith를 통해서 사용할 수 있었다.
@RunWith(CustomRunner.class)
class JUnit4Test {
// ...
}
단, RunWith 는 오직 하나의 Runner만 사용할 수 있고 다른 Runner를 사용하고 싶으면 직접 Runner의 설정을 해줘야 했다.
그런 문제를 해결하고자, JUnit 5에서는 맞춤 Runner 또는 Rule 클래스를 구현하는 대신 @ExtendWith 애노테이션을 사용하여 Extension API를 사용할 수 있다.
기존의 Runner 모델과 달리, 단일 클래스에 여러 확장을 제공할 수 있다는 점이 큰 차이점이다.
@ExtendWith(CustomExtensionOne.class)
@ExtendWith(CustomExtensionTwo.class)
class JUnit5Test {
// ...
}
Junit4에서 @RunWith(MockitoJUnitRunner.class) 를 사용하는 것과 MockitoAnnotations.initMocks(this) 을 사용하는 것의 차이점은 무엇이고 어떤 장점이 있을까?
MockitoJUnitRunner는 Mockito 프레임워크를 위한 전용 러너다.
MockitoJUnitRunner는 크게 2가지를 제공한다.
1. 자동 initMocks()
2. 프레임워크 사용의 자동 검증
1. 자동 initMocks()
@Mock, @Spy, @InjectMock 애노테이션을 초기화하는 역할을 하므로 MockitoAnnotations.openMocks()의 명시적 사용이 필요없다.
2. 프레임워크 사용의 자동 검증
프레임워크 사용의 자동 검증은 크게 3가지의 검증을 자동으로 해줘서 사용자가 더 안전한 테스트를 짤 수 있게 도와준다.
- static 메서드 when을 호출했지만, thenReturn, thenThrow 또는 then과 일치하는 스터빙을 완료하지 않은 경우 (아래 코드의 오류 1)
- mock 객체에 대해 verify를 호출했지만, 검증하려는 메서드 호출을 제공하지 않은 경우 (아래 코드의 오류 2)
- doReturn, doThrow 또는 doAnswer 후에 when 메서드를 호출하고 mock 객체를 전달했지만, 스터빙하려는 메서드를 제공하지 않은 경우 (아래 코드의 오류 3)
프레임워크 사용의 검증이 없으면, 이러한 실수는 다음 Mockito 메서드 호출 시까지 알 수가 없다.
다음과 같은 예시코드를 보면 더 이해가 쉽다.
@Test
public void test1() {
// 오류 1
// 이 코드는 컴파일되고 실행되지만, 프레임워크의 잘못된 사용입니다.
// Mockito는 여전히 myMethod가 호출될 때 무엇을 해야 할지 기다리고 있습니다.
// 그러나 thenReturn 호출이 아직 일어나지 않았을 수 있기 때문에 Mockito는 아직 이를 보고할 수 없습니다.
when(myMock.method1());
doSomeTestingStuff();
// 오류 1은 아래 줄에서 보고됩니다. 오류가 발생한 줄이 아님에도 불구하고 말입니다.
verify(myMock).method2();
}
@Test
public void test2() {
doSomeTestingStuff();
// 오류 2
// 이 코드는 컴파일되고 실행되지만, 프레임워크의 잘못된 사용입니다.
// Mockito는 어떤 메서드 호출을 검증해야 할지 알지 못합니다.
// 그러나 검증하려는 메서드 호출이 아직 일어나지 않았을 수 있기 때문에 Mockito는 아직 이를 보고할 수 없습니다.
verify(myMock);
}
@Test
public void test3() {
// 오류 2는 아래 줄에서 보고됩니다. 오류가 발생한 테스트와는 다른 테스트임에도 불구하고 말입니다.
doReturn("Hello").when(myMock).method1();
// 오류 3
// 이 코드는 컴파일되고 실행되지만, 프레임워크의 잘못된 사용입니다.
// Mockito는 어떤 메서드 호출이 스터빙되었는지 알지 못합니다.
// 그러나 스터빙하려는 메서드 호출이 아직 일어나지 않았을 수 있기 때문에 Mockito는 아직 이를 보고할 수 없습니다.
doReturn("World").when(myMock);
doSomeTestingStuff();
// 오류 3은 전혀 보고되지 않습니다. 더 이상 Mockito 호출이 없기 때문입니다.
}
그렇기 때문에 MockitoJUnitRunner의 사용을 권장하는 것이다.
만약 Junit4를 사용하는데 MockitoJUnitRunner를 사용할 수 없는 상황이라면?
다른 Runner와 함께 사용할 수 있는 Mockito 팀의 새로운 권장사항이 추가되었다.
테스트 클래스에 다음과 같은 어노테이션을 추가하면 된다.
@Rule
public MockitoRule rule = MockitoJUnit.rule();
이것은 mock 객체를 초기화하고, MockitoJUnitRunner처럼 프레임워크 검증을 자동화합니다.
하지만 다른 JUnitRunner를 사용할 수도 있다는 장점이 있다. Mockito 2.1.0 이상 부터 해당 기능은 제공된다.
@ExtendWith(SpringExtension.class) 와 @ExtendWith(MockitoExtension.class)의 차이점
부끄럽지만 이 차이점을 몰라서 한참동안 헤맸었다.
MockitoExtension.class
MockitoJUnitRunner는 Mockito 프레임워크를 위한 전용 러너다.
@Mock, @Spy, @InjectMock 애노테이션을 초기화하는 역할을 하므로 MockitoAnnotations.openMocks()의 명시적 사용이 필요없다. 또한, 테스트 메서드마다 mock 사용을 검증하고, 사용되지 않은 스텁을 감지한다.
이건 위쪽에서 설명했던 내용과 같다.
다만 내가 몰랐던 부분은 MockitoExtension.class와 SpringExtension.class의 차이점이었다.
SpringExtension.class
스프링을 포함하는 코드는 @ExtendWith(SpringExtension.class) 를 사용한다.
@MockBean 이나 그 외의 기능들을 쓸 때를 이야기 한다. 즉 위쪽의 MockitoExtension.class는 불필요한 Spring 객체를 로드하지 않아서 테스트를 효율적이고 속도를 빠르게 한다는 장점이 있다.
사실 SpringExtension을 설정해주면 MockitoExtension은 설정해줄 필요가 없다.
SpringExtension.class 만 사용해도 모킹 객체가 제대로 초기화되고, 게다가 Spring 컨텍스트도 시작된다.
새로운 객체를 생성하는 대신 Spring 빈으로 주입할 수 있다. Spring 테스트 모듈에는 Mockito 통합이 기본적으로 제공되며, @MockBean 및 @SpyBean 애노테이션을 제공하여 모킹과 빈 기능을 통합할 수 있다.
Spring Security에서 커스텀 유저 Context에 추가하기
이제 내가 겪은 문제이다.
https://docs.spring.io/spring-security/reference/servlet/test/method.html#test-method-withsecuritycontext
다음 공식문서를 보고 아래와 같이 유저를 Context에 추가해줬다.
공식 문서에도 나와있듯, Custom으로 유저를 만들었으면, 다른 어노테이션이 아니라 WithMockCustomUserSecurityContextFactory를 사용하는 방식을 가장 추천한다고 한다.
내 스프링 시큐리티에서 사용하는 유저는 커스텀으로 만들었기 때문에 @WithMockUser 를 사용할 수 없었다.
public class SecurityUser implements UserDetails, UserInfo {
private final Long userId;
private final Role role;
다음과 같기 때문에
public class WithMockCustomUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
System.out.println("customUser: " + customUser);
SecurityContext context = SecurityContextHolder.createEmptyContext();
SecurityUser securityUser = SecurityUser.of(customUser.id(), customUser.role());
System.out.println("securityUser: " + securityUser);
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(securityUser, null,
securityUser.getAuthorities());
context.setAuthentication(auth);
return context;
}
}
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
long id() default 1L;
String role() default "USER";
}
컨텍스트를 다음과 같이 설정해줘서 spring Security Context에 Mock으로 만든 유저가 들어갔다.
@ExtendWith(SpringExtension.class)
@DisplayName("회의방 컨트롤러 테스트")
class MeetRoomControllerTest {
@InjectMocks
private MeetRoomController meetRoomController;
@Mock
private MeetRoomService meetRoomService;
private MockMvc mockMvc;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(meetRoomController).build();
objectMapper = new ObjectMapper();
}
@Test
@DisplayName("방 생성")
void testCreateRoom() throws Exception {
CreateRoomDto createRoomDto = new CreateRoomDto("회의방");
List<UserInfoDto> userInfoList = new ArrayList<>();
UserInfoDto userInfoDto = new UserInfoDto(1L, "이름", "이메일", "사진");
userInfoList.add(userInfoDto);
JoinResponseDto joinResponseDto = new JoinResponseDto(1L, "회의방", userInfoList, 1L);
when(meetRoomService.createAndJoinRoom(any(CreateRoomDto.class)))
.thenReturn(joinResponseDto);
mockMvc.perform(post("/meet/room/create")
.contentType("application/json")
.content(objectMapper.writeValueAsString(createRoomDto))) // CreateRoomDto 객체를 JSON 문자열로 변환
.andExpect(status().isOk())
.andExpect(jsonPath("$.roomId").value(joinResponseDto.getRoomId()))
.andExpect(jsonPath("$.roomName").value(joinResponseDto.getRoomName()))
.andExpect(jsonPath("$.userInfoList[0].userId").value(userInfoDto.getUserId()))
.andExpect(jsonPath("$.userInfoList[0].userName").value(userInfoDto.getUserName()))
.andExpect(jsonPath("$.userInfoList[0].email").value(userInfoDto.getEmail()))
.andExpect(jsonPath("$.userInfoList[0].userImage").value(userInfoDto.getUserImage()))
.andExpect(jsonPath("$.userCount").value(joinResponseDto.getUserCount()));
}
@Test
@DisplayName("방 나가기 테스트")
@WithMockCustomUser
void testLeaveRoom() throws Exception {
mockMvc.perform(post("/meet/room/leave"))
.andExpect(status().isOk());
}
}
계속해서 테스트를 정상적으로 짠 것 같은데 테스트를 실패해서 그 이유를 찾아보니까 Spring Security를 다 설정해두고, @ExtendWith(SpringExtension.class) 가 아니라 @ExtendWith(MockitoExtension.class) 라고 적어놔서 자꾸만 컨텍스트를 가져오지 못했다.
또, 다른 문제도 있었는데,
@PostMapping("/leave")
@Operation(summary = "회의방 나가기", description = "현재 참여중인 회의방을 나갑니다.")
public void leaveRoom(@AuthenticationPrincipal SecurityUser user) {
Long currentUserId = SecurityUtil.getCurrentUserId();
System.out.println("currentUserId = " + currentUserId);
System.out.println(user);
System.out.println("user.getUserId() = " + user.getUserId());
meetRoomService.leaveRoom(user.getUserId());
}
지금은 시큐리티 유틸을 사용해서 다 처리하지만 이전 코드에서는 인증 정보를 @AuthenticationPrincipal 를 통해서 받아왔었다.
이 사진을 보면 알 수 있듯, 올바로 유저가 모킹 됐음에도 AuthenticationPrincipal을 통해서 인증된 SecurityUser는 두개 다 null인 값이 들어오는 것을 볼 수가 있다.
나는 시큐리티 컨텍스트를 접근하는 전역에서 접근하는 정적 클래스를 통해서 문제를 해결했다.
public class SecurityUtil {
private SecurityUtil() {
throw new IllegalStateException("Utility class");
}
public static UserDetails getCurrentUserDetails() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof UserDetails) {
return (UserDetails)authentication.getPrincipal();
}
throw new IllegalStateException("User not authenticated");
}
public static Long getCurrentUserId() {
UserDetails userDetails = getCurrentUserDetails();
if (userDetails instanceof SecurityUser) { // Assuming SecurityUser extends UserDetails
return ((SecurityUser)userDetails).getUserId();
}
throw new IllegalStateException("UserDetails does not contain userId");
}
}
@AuthenticationPrincipal 을 통해서 가져오는 인증 유저 정보는 Context에 설정 시점이 달라서 발생한 문제인 것 같다.
이것을 해결하기 위해서는 아래의 시도할만한 두가지 링크를 첨부할 수 있지만, 굳이 이 방법을 사용할 것 같지는 않다.
- https://stackoverflow.com/questions/52180555/mock-authenticationprincipal-argument
- https://jskim1991.medium.com/spring-boot-how-to-stub-authenticationprincipal-argument-with-spring-security-in-unit-tests-590095caec61
결론
스프링 시큐리티를 사용할 때 잘 이해하지 못하고 사용하는 느낌이 강했다.
이번 문제를 해결하면서 스프링 시큐리티를 제대로 알고 쓰지 않으면 이런 간단한 문제에도 한참을 고생한다는 것을 알 수 있었다.
그동안 아무 생각없이 ExtendWith와 Spring Security 를 사용했고, 사소하다고 생각했던 부분에서 기초가 부족하면 문제 해결에 오랜 시간이 걸린다는 걸 느낀 시간이었다.
스프링 시큐리티 인 액션 책을 샀는데, 짬짬히 읽어가면서 기술 부채를 채워가야겠다.
참고링크
- https://www.baeldung.com/junit-springrunner-vs-mockitojunitrunner
- https://docs.spring.io/spring-security/reference/servlet/test/method.html#test-method-withmockuser
- https://stackoverflow.com/questions/10806345/runwithmockitojunitrunner-class-vs-mockitoannotations-initmocksthis?rq=3
- https://stackoverflow.com/questions/60308578/what-is-the-difference-between-extendwithspringextension-class-and-extendwit
- https://docs.spring.io/spring-security/site/docs/4.2.x/reference/html/test-method.html
'Backend' 카테고리의 다른 글
sqs spring boot 사용법 (0) | 2024.08.13 |
---|---|
[문제해결] Spring webSocket Test 삽질 일기 (생성자 직렬화 문제) (0) | 2024.08.07 |
[문제 해결] github Action + AWS CodeDeploy 배포 환경변수 삽질일기. (2) | 2024.08.07 |
서블릿이란 무엇인가 [서블릿 실행 흐름 편] (0) | 2024.05.08 |
Servlet 이란 무엇일까 [개념편] (0) | 2024.05.07 |