최근에 Spring Security를 활용하여 일반 로그인과 OAuth2 로그인을 모두 호환하고 JWT로 사용자를 인증하는 API 개발을 진행했다. 매번 개발할 때마다 단위테스트를 하자, 하자 해놓고 시간이 없다는 핑계로 미루다가 이번에 단위테스트에 대해서 제대로 배울 겸 OAuth2UserService 서비스 코드에 대해서 단위테스트를 진행해 보았다.
단위테스트란 코드의 가장 작은 단위, 즉 메소드들을 예상한 시나리오대로 동작하는지 검증하는 테스트이다. 클래스에서 일어날 수 있는 상황들을 예측하고 이런 상황을 가정하여 테스트를 진행한다.
단위테스트의 핵심은 빠르게 실행하고, 테스트 코드만 보고도 테스트 대상이 어떤 서비스를 하는지 예상할 수 있어야하고, 무엇보다도 외부 환경(DB나 네트워크)에 의존하지 않는 것이다.
단위테스트의 주요 개념
- JUnit5 : 자바 진영의 표준 테스트 프레임워크로 테스트 실행 흐름을 제어하는 역할을 한다.
@Test,@DisplayName같은 어노테이션으로 테스트 메서드를 실행하고,assertThrows,assertEquals등 기본적인 검증 메서드를 제공한다.- AssertJ : JUnit5의 검증 메소드와 비교 했을 때 더 가독성이 좋은 검증 문법을 제공하는 라이브러리. 메서드 체이닝 방식으로 메소드 작성이 가능하고 다양한 검증 방식을 제공한다.
- Mock : 실제 객체의 동작을 흉내내는 완전한 가짜 객체. 가짜 객체이기 때문에 내부 로직 자체가 없으며
when(...).thenReturn(...)문법으로 반환 값을 지정할 수 있다. 외부 환경에 의존하지 않기 위한 목적으로 사용한다.- Mockito : Mock객체를 쉽게 만들고 Mock객체의 동작 정의, 호출 여부 검증 등을 도와주는 프레임워크
- Spy : 실제 객체를 감싸는 프록시 객체로 감싸는 객체의 실제 로직을 실행하지만 특정 메소드에 대해서 가짜 동작을 정의할 수 있다. 즉 Mock객체와 비교했을 때 전체가 아닌 일부 기능에 대해서 Mocking하고 싶을 때 사용한다.
이제 본격적으로 단위테스트를 진행해보자. 우선 테스트를 할 서비스 코드는 아래와 같다.
@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;
}
protected OAuth2User loadUserFromProvider(OAuth2UserRequest request) {
return super.loadUser(request);
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
String provider = userRequest.getClientRegistration().getRegistrationId();
OAuth2User oAuth2User = loadUserFromProvider(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());
}
}
이 서비스의 주요 역할은 다음과 같다.
1️⃣부모 클래스의 loadUser()를 호출해 소셜 로그인 사용자 정보를 로드
2️⃣로그인 제공자(ex. kakao, google)에 따라 적절한 DTO로 매핑
3️⃣ provider_providerId 형태의 username을 생성하여, userRepository.findByUsername()로 사용자 존재 여부를 확인
4️⃣사용자가 없으면 회원가입을 처리
5️⃣최종적으로 인증 정보를 담은 OAuth2User를 반환
우선 예상 가능한 시나리오를 생각해야 한다. 이 서비스에서 OAuth2 provider는 Google과 Kakao 뿐이다. 그렇기 때문에 우선 Google과 Kakao가 아닌 provider가 들어오면 OAuth2AuthenticationException 예외를 던져야 한다. 또한 로그인한 사용자가 기존에 존재하는 회원이라면 바로 OAuth2User를 반환하지만, 최초의 회원이라면 회원가입을 처리해야 한다. 이를 적절히 조합하여 아래와 같은 3가지 시나리오를 구상할 수 있다.
테스트 시나리오
1️⃣ 구글로 신규 회원가입 시, 새로운 User 객체를 생성하여 저장하고 반환한다.
2️⃣ 카카오로 기존 회원이 로그인하면 새로운 유저를 저장하지 않고 반환한다.
3️⃣ 지원하지 않는 소셜 로그인이면 예외를 던진다.
이를 기반으로 최종 작성된 테스트 코드는 아래와 같다. 단위테스트이기 때문에 실제 구글/카카오 API 호출 없이 CustomOAuth2UserService의 로직만을 검증하였다. 공부를 위하여 설명은 코드 내부에서 주석을 통해 작성하였다.
@ExtendWith(MockitoExtension.class) // Mockito를 사용하기 위한 확장 설정
class CustomOAuth2UserServiceTest {
@Mock // @Mock: 가짜 객체(Mock)를 만들어 줌. 실제 DB나 외부 의존성 없이 테스트 가능
private UserRepository userRepository; // 실제 DB와 상호작용 하지 않음
@Mock
private PasswordEncoder passwordEncoder; // 실제 인코딩 로직을 실행하지 않음
@InjectMocks // @InjectMocks: 위의 Mock 객체들을 자동으로 주입해서 테스트할 서비스 객체를 생성
private CustomOAuth2UserService customOAuth2UserService;
// [테스트 시나리오 1]
@Test
@DisplayName("구글로 신규 회원가입 시, 새로운 User 객체를 생성하여 저장하고 반환한다.")
void signUpWithGoogleSuccess() throws OAuth2AuthenticationException {
// [Given]
// 필요한 Mock 객체를 생성
OAuth2UserRequest oAuth2UserRequest = mock(OAuth2UserRequest.class);
OAuth2User oAuth2User = mock(OAuth2User.class);
// Mock 객체의 동작을 정의
when(oAuth2UserRequest.getClientRegistration()).thenReturn(mock(ClientRegistration.class));
when(oAuth2UserRequest.getClientRegistration().getRegistrationId()).thenReturn("google"); // google을 반환하도록 설정. provider에 google 저장
// 실제 서비스 객체를 Spy로 감싸 일부 메서드(ex. loadUserFromProvider)의 동작을 Mocking
CustomOAuth2UserService spyService = spy(customOAuth2UserService);
doReturn(oAuth2User).when(spyService).loadUserFromProvider(oAuth2UserRequest);
// oAuth2User.getAttributes()가 반환할 가짜 사용자 속성(attributes)을 설정
Map<String, Object> attributes = new HashMap<>();
attributes.put("sub", "123456789");
attributes.put("email", "test@gmail.com");
attributes.put("name", "홍길동");
when(oAuth2User.getAttributes()).thenReturn(attributes);
// UserRepository가 기존 유저를 찾지 못하도록 설정
when(userRepository.findByUsername(anyString())).thenReturn(Optional.empty());
// PasswordEncoder가 어떤 문자열을 인코딩해도 "encoded_password"를 반환하도록 설정
when(passwordEncoder.encode(anyString())).thenReturn("encoded_password");
// [When]
OAuth2User result = spyService.loadUser(oAuth2UserRequest);
// [Then]
// ArgumentCaptor: Mockito에서 메서드 호출 시 전달된 인자를 가로채서 검증할 수 있게 해주는 도구
// 여기서는 userRepository.save()에 전달된 User 객체를 캡처하기 위해 사용
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
// userRepository.findByUsername() 메소드가 "google_123456789"를 인자로 한 번 호출되었는지 확인
verify(userRepository, times(1)).findByUsername(eq("google_123456789"));
// userRepository의 save()메소드가 한 번 호출되었는지 확인, 이때 전달된 User 객체를 userCaptor에 담기
verify(userRepository, times(1)).save(userCaptor.capture());
// capturedUser - userRepository.save()에 전달된 User 객체
User capturedUser = userCaptor.getValue();
// Repository에 제대로 값이 전달되었는지 확인
assertThat(capturedUser.getUsername()).isEqualTo("google_123456789");
assertThat(capturedUser.getEmail()).isEqualTo("test@gmail.com");
assertThat(capturedUser.getNickname()).isEqualTo("홍길동");
assertThat(capturedUser.getPassword()).isEqualTo("encoded_password");
// 최종 반환된 PrincipalDetails 객체의 username 필드 확인
assertThat(((PrincipalDetails) result).getUsername()).isEqualTo("google_123456789");
}
// [테스트 시나리오 2]
@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).loadUserFromProvider(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");
}
// [테스트 시나리오 3]
@Test
@DisplayName("지원하지 않는 소셜 로그인이면 예외를 던진다.")
void unsupportedLoginProviderThrowsException() {
// [Given]
OAuth2UserRequest oAuth2UserRequest = mock(OAuth2UserRequest.class);
OAuth2User oAuth2User = mock(OAuth2User.class);
when(oAuth2UserRequest.getClientRegistration()).thenReturn(mock(ClientRegistration.class));
when(oAuth2UserRequest.getClientRegistration().getRegistrationId()).thenReturn("kbBank"); // provider에 "kakao", "google"이 아닌 값이 들어감
CustomOAuth2UserService spyService = spy(customOAuth2UserService);
doReturn(oAuth2User).when(spyService).loadUserFromProvider(oAuth2UserRequest);
// When + Then
// loadUser 메소드 실행 시 OAuth2AuthenticationException이 발생하는지 검증
OAuth2AuthenticationException exception = assertThrows(OAuth2AuthenticationException.class, () -> {
spyService.loadUser(oAuth2UserRequest);
});
// 발생한 예외의 메시지가 예상과 일치하는지 확인
assertEquals("지원하지 않는 소셜 로그인입니다.", exception.getError().getErrorCode());
}
}
위 각각의 테스트 메소드의 코드 작성 방식을 보면 Givne-When-Then방식으로 일관화하여 작성한 것을 볼 수 있다. 이는 테스트 코드를 작성할 때 사용하는 대표적인 패턴으로, 테스트 코드를 읽기 쉽게 구조화하는 패턴이다.
Given은 테스트 실행 전에 필요한 상태와 데이터 준비하고,
When은 테스트 대상 메서드를 실행하고,
Then은 실행 결과를 검증한다.
위 테스트 코드를 실행하여 테스트를 확인하면 아래 화면처럼 테스트가 성공한 것을 알 수 있다.

단위테스트의 목적은 이렇게 코드의 가장 작은 단위가 의도한 대로 동작하는지 빠르게 확인하는 것에 있다. 이를 통해 변경 사항이 생겨도 로직에 문제가 없는지 조기에 파악할 수 있다.
다만, 외부 시스템과의 연동이 없이 진행하므로, 외부 연동 문제나 전체 플로우에서 발생할 수 있는 예외 상황까지는 보장할 수 없다. 그렇기 때문에 이후에 통합테스트와 같은 상위 레벨의 테스트가 함께 필요하다.
참고로 위 CustomOAuth2UserService.java에서 부모 클래스의 loadUser()를 호출하는 부분을 별도 메소드로 추출한 이유는 아래 포스팅에 작성하였다.
위 단위테스트 진행 과정에서 겪은 문제와 해결과정을 정리한 포스트