지난 포스팅(Junit5와 AssertJ를 활용하여 단위테스트를 진행)에서 OAuth2UserService 서비스 코드에 대해서 단위테스트를 진행했다.
해당 포스팅에서는 다루지 않았지만, 처음에는 테스트 코드를 실행하였더니 테스트 실패가 떴었고 그 원인은 Spy에 대한 잘못된 사용이었다. 이후에 적절한 조치를 취하고 최종 테스트 코드를 수정하여 테스트를 성공적으로 마쳤다. 이 글에서는 당시 실패 원인과 수정 과정을 정리하였다.
단위테스트 진행 당시 처음의 CustomOAuth2UserService.java 코드는 아래와 같았다.
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public CustomOAuth2UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
String provider = userRequest.getClientRegistration().getRegistrationId();
// 테스트 실패 후 이 부분에 대해서 수정함‼️
OAuth2User oAuth2User = super.loadUser(userRequest);
OAuth2Response oAuth2Response = null;
if (provider.equals("google")) {
oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
}
else if (provider.equals("kakao")) {
oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
}
else {
throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인입니다.");
}
String username = provider + "_" + oAuth2Response.getProviderId();
User user = userRepository.findByUsername(username).orElse(null);
if (user == null) {
user = User.createOAuthUser(
oAuth2Response.getEmail(),
oAuth2Response.getNickname(),
username,
passwordEncoder.encode("oauth2")
);
userRepository.save(user);
}
return new PrincipalDetails(user, oAuth2User.getAttributes());
}
}
주석으로도 표시한
OAuth2User oAuth2User = super.loadUser(userRequest);
이 부분에 대해 테스트 실패 이후 리팩토링하였다. 이 부분은 이 클래스의 핵심 로직이다. CustomOAuth2UserService의 loadUser()를 실행하면 내부에서 부모클래스의 loadUser(userRequest)를 호출하여 OAuth2 서버로부터 사용자 정보를 가져온다.
즉, super.loadUser()가 실제 외부 서버 호출을 발생시키는 메서드이기 때문에 단위테스트의 원칙 중 외부 환경과의 독립성이 위배가 된다.
그렇다면 super.loadUser() 이 코드를 Mocking 처리할 필요가 있으며 적절한 대안은 Spy를 사용하는 것이다.
우선 외부 환경으로부터 의존성을 제거하기 위해 테스트 코드를 작성할 때 주로 Mock객체를 사용한다. 하지만 만약 customOAuth2UserService를 Mock객체로 만들어 버리면, 모든 메서드가 가짜가 되어버린다.
이런 경우, loadUser() 내부 로직 자체가 실행되지 않기 때문에 회원 조회/가입 로직을 검증할 수 없다.
이 때문에, 핵심 로직은 그대로 실행하면서, 내부의 super.loadUser() 호출 부분만 대체하는 방법이 필요했다.
그래서 Spy를 활용하여 테스트 코드를 작성하였다.
Spy는 실제 객체를 감싸는 프록시로, 기본적으로 원본 객체의 메서드를 그대로 실행하지만 별도로 지정한 메서드만 부분적으로 가짜 동작(Stub)으로 처리할 수 있다.
Stub이란?
테스트 대역(Test Double)의 한 종류로, 호출 시 미리 정해둔 값을 반환하도록 만든 객체.
실제 구현 로직을 실행하지 않고, 예측 가능한 결과를 돌려주는 것이 목적.
@Test
@DisplayName("카카오로 기존 회원이 로그인하면 새로운 유저를 저장하지 않고 반환한다.")
void loginWithKakaoSuccess() throws OAuth2AuthorizationException {
// Given
OAuth2UserRequest oAuth2UserRequest = mock(OAuth2UserRequest.class);
OAuth2User oAuth2User = mock(OAuth2User.class);
User existedUser = User.createOAuthUser("test@naver.com", "홍길동", "kakao_123456", "encoded_password");
Map<String, Object> attributes = new HashMap<>();
attributes.put("id", 123456);
attributes.put("kakao_account", Map.of("email", "test@naver.com", "profile", Map.of("nickname", "홍길동")));
when(oAuth2UserRequest.getClientRegistration()).thenReturn(mock(ClientRegistration.class));
when(oAuth2UserRequest.getClientRegistration().getRegistrationId()).thenReturn("kakao");
when(userRepository.findByUsername(eq("kakao_123456"))).thenReturn(Optional.of(existedUser));
// 테스트 실패 후 이 부분에 대해서 수정함‼️
CustomOAuth2UserService spyService = spy(customOAuth2UserService);
doReturn(oAuth2User).when(spyService).loadUser(oAuth2UserRequest);
when(oAuth2User.getAttributes()).thenReturn(attributes);
// When
OAuth2User result = spyService.loadUser(oAuth2UserRequest);
// Then
verify(userRepository, times(1)).findByUsername(eq("kakao_123456"));
verify(userRepository, never()).save(any(User.class));
assertThat(((PrincipalDetails) result).getUsername()).isEqualTo("kakao_123456");
}
위와 같이 테스트 코드를 작성하고 해당 단위테스트를 진행하니 처음에 아래와 같은 테스트 실패가 떴다.
Wanted but not invoked:
userRepository.findByUsername(
"kakao_123456"
);
-> at com.example.finlight.global.auth.oauth.CustomOAuth2UserServiceTest.loginWithKakaoSuccess(CustomOAuth2UserServiceTest.java:139)
Actually, there were zero interactions with this mock.
이 에러는 userRepository.findByUsername()이 호출되지 않고 userRepository와 상호작용한 기록이 없음을 의미한다.
그리고 문제의 테스트 코드 부분은
doReturn(oAuth2User).when(spyService).loadUser(oAuth2UserRequest);
바로 이 부분이었다.
loadUser()은 테스트 대상 메소드이다. 처음에는 위 코드가 super.loadUser(userRequest)에 대해서 Stub 처리해 줄 것이라고 기대했다.
하지만 위 코드는 테스트 대상인 CustomOAuth2UserService.loadUser() 자체를 Stub 처리 한 것이다. 이 결과, 내부 로직 자체가 실행되지 않아서 내부에서 호출되어야 할 userRepository.findByUsername() 역시 호출되지 않았고, 위와 같은 테스트 실패 메세지가 출력되었다.
Spy의 활용 목적을 다시 생각하면, 테스트 대상 메서드인 loadUser()는 실제로 실행하되,
그 내부에서 호출되는 외부 의존 메서드만 Mocking해야 한다.
즉 OAuth2 서버와 통신을 발생시키는 super.loadUser(userRequest)에 대해서 별도의 메소드로 추출하고 해당 메소드를 Stub 처리해야 한다.
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
생략...
// loadUserFromProvider()로 메소드 추출‼️
protected OAuth2User loadUserFromProvider(OAuth2UserRequest request) {
return super.loadUser(request);
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
생략...
// 추출한 메소드를 실행하여 super.loadUser()를 호출‼️
OAuth2User oAuth2User = loadUserFromProvider(userRequest);
생략...
}
}
위와 같이 수정된 클래스에 맞게 아래 테스트 코드도 수정해주었다.
@Test
@DisplayName("카카오로 기존 회원이 로그인하면 새로운 유저를 저장하지 않고 반환한다.")
void loginWithKakaoSuccess() throws OAuth2AuthorizationException {
생략...
CustomOAuth2UserService spyService = spy(customOAuth2UserService);
// 수정된 부분 - loadUserFromProvider()에 대해서만 가짜 동작 처리‼️
doReturn(oAuth2User).when(spyService).loadUserFromProvider(oAuth2UserRequest);
생략...
}
이렇게 수정하면 테스트 대상인 CustomOAuth2UserService의 loadUser()메소드의 내부 로직은 정상적으로 실행된다.
테스트도 성공적으로 통과하였다.
이런 문제 해결 과정을 겪으면서 Spy에 대한 활용법과 목적을 다시 한 번 확인할 수 있었다.
Spy는 테스트하려는 메소드 A를 직접 Mocking하는 것이 아니라, A의 실제 로직을 실행시키되, 그 안에서 호출되는 제어하기 힘든 의존 메소드 B의 동작을 가짜로 바꾸고 싶을 때 사용하는 것이다.
만약 그 외부 호출이 super.loadUser()처럼 부모 클래스의 메서드나 직접 접근이 어려운 코드라면, 별도의 보호 메서드로 추출한 뒤 Stub 처리하는 것이 대안이다.
이렇게 하면 핵심 로직은 실제 실행되면서도, 외부 환경과의 의존성을 제거한 테스트를 작성할 수 있다.