Spring/기초 개념

SpringBoot에서 Mockito로 소셜 로그인 단위 테스트

kym8821 2025. 2. 14. 21:47

Mockito를 활용하여 Mock 객체 생성하기

단위 테스트는 테스트 대상인 모듈에 대한 독립적인 테스트를 진행한다.

따라서 모듈의 의존성에 대한 Mock 객체를 생성 및 주입할 필요가 있다.

모킹과 스터빙

  • 모킹 : 테스트 대상이 외부 요소와 어떻게 상호작용했는지 확인하는 것
    • 주로 외부 메서드가 호출되었는지 검증하기 위해 사용한다
  • 스터빙 : 테스트 대상이 외부 요소로부터 어떤 값을 받는지를 정의하는 것
    • 주로 외부 메서드를 호출했을 때의 반환값을 정의하기 위해 사용한다

Spring Context와 무관한 Mock 객체 생성

아래 어노테이션을 활용하면 Spring Context와 무관한 Mock 객체를 생성할 수 있다.

어노테이션 기능 사용처
@Mock 실제 기능이 없는 가짜 객체 생성 1. 외부와 독립적인 테스트를 진행하기 위해 사용
2. 스터빙을 통해 메서드 반환값 설정 가능
@Spy 특정 기능을 모킹 및 스터빙할 수 있는 실제 객체 생성 실제 객체와 목 객체의 혼합 사용이 필요할 때 사용
@InjectMocks @Mock와 @Spy를 자동 주입한 객체 생성 테스트 대상 클래스를 생성하기 위해 사용함

Spring Context에서 관리되는 Mock 객체 생성

해당 어노테이션으로 생성한 Mock 객체는 Spring Context에서 관리한다.

따라서 해당 방법으로 생성한 객체에는 InjectMocks로의 자동 주입 등의 상황은 발생하지 않는다.

어노테이션 기능
@MockBean Spring Context의 기존 빈을 목 객체로 대체
@SpyBean Spring Context의 기존 빈을 스파이 객체로 대체

 

Spring Context를 로드해야 하므로 주로 통합 테스트에서 자주 사용되는 기술들이다.

단위 테스트에서는 @Mock, @Spy 등 Spring Context에서 관리하지 않는 Mock 객체를 생성하는 것이 좋다.

테스트 대상 정리 및 목표 수립

테스트 대상 정리

아래 세개의 메서드에 대한 독립적인 테스트를 진행한다.

해당 메서드들은 모두 같은 클래스에 위치한다.

  • getGoogleAccessToken : 구글 인증 코드를 활용하여 구글 access token을 받아옴
  • getGoogleUserInfo : 구글 access token을 활용하여 구글 사용자 정보를 받아옴
  • signIn : 위 두 메서드를 활용하여 사용자 등록 및 로그인 절차 수행

코드는 아래 더보기를 확인하자.

더보기

 

@Service
public class UserGoogleAuthService {
    @Value("${env.auth.google.client-id}")
    private String clientId;
    @Value("${env.auth.google.client-secret}")
    private String clientSecret;
    @Value("${env.auth.google.redirect-uri}")
    private String redirectUri;
    @Value("${env.auth.google.access-token-uri}")
    private String googleAccessTokenUri;
    @Value("${env.auth.google.user-info-uri}")
    private String googleUserInfoUri;
    private final RestTemplate restTemplate = new RestTemplate();
    private final UserRepository userRepository;
    private final UserAuthService userAuthService;
    public UserGoogleAuthService(
            UserRepository userRepository,
            UserAuthService userAuthService
    ) {
        this.userRepository = userRepository;
        this.userAuthService = userAuthService;
    }
    /**
     * get Google access token by using authentication code
     * @param authCode client's authentication code
     * @return access token from Google
    * */
    public String getGoogleAccessToken(String authCode){
        String decodedAuthCode = URLDecoder.decode(authCode, StandardCharsets.UTF_8);
        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE);
        HttpEntity<GoogleTokenRequestDto> httpEntity = new HttpEntity<>(
                new GoogleTokenRequestDto(clientId, clientSecret, redirectUri, decodedAuthCode, "authorization_code")
                , headers
        );
        GoogleTokenResponseDto googleTokenResponseDto = restTemplate.exchange(
                googleAccessTokenUri, HttpMethod.POST, httpEntity, GoogleTokenResponseDto.class
        ).getBody();
        return Optional.ofNullable(googleTokenResponseDto)
                .orElseThrow(() -> new RuntimeException("")).getAccess_token();
    }
    /**
     * get user info from Google by using access token
     * @param accessToken user's access token from Google
     * @return user's info from Google
    * */
    public GoogleUserInfoResponseDto getGoogleUserInfo(String accessToken){
        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
        HttpEntity<String> httpEntity = new HttpEntity<>(headers);
        return restTemplate.exchange(
                googleUserInfoUri, HttpMethod.GET, httpEntity, GoogleUserInfoResponseDto.class
        ).getBody();
    }
    /**
     * sign in user by using Google auth code
     * @param authCode authentication Code from Google
     * @return UserAuthResponseDto with access token and refresh token
    * */
    @Transactional
    public UserAuthResponseDto signIn(String authCode){
        String accessToken = getGoogleAccessToken(authCode);
        GoogleUserInfoResponseDto userInfo = getGoogleUserInfo(accessToken);
        User existUser = userRepository.findByEmail(userInfo.getEmail()).orElse(null);
        if(existUser == null){
            userRepository.save(
                    User.builder()
                            .email(userInfo.getEmail())
                            .role(UserRole.ROLE_USER)
                            .password(null)
                            .nickname(userInfo.getName()).build()
            );
        }
        return userAuthService.generateResponseAndSaveToken(userInfo.getEmail(), UserRole.ROLE_USER);
    }
}

테스트 목표 수립

  • 구글의 auth code 관리 및 요청 전송
    • auth code는 만료 시간이 있는 값이고 사용자로부터 받는 값이다.
    • 테스트에서는 구글이 아닌 mockWebServer의 가상 서버로 요청을 보낸다.
  • 테스트 대상 메서드 간 독립성 보장
    • signIn 메서드는 다른 두 메서드를 사용한다.
    • ▶ signIn 테스트 시 다른 두 메서드는 정상 작동을 가정하고 스터빙할 예정이다.

 

단위 테스트 코드 작성하기

테스트 사전 준비하기

아래와 같이 테스트에 필요한 사전 준비를 진행했다. 

@ExtendWith(MockitoExtension.class)
class UserGoogleAuthServiceTest {
    private MockWebServer mockWebServer;
    @Spy
    @InjectMocks
    private UserGoogleAuthService userGoogleAuthService;
    @Mock
    private UserRepository userRepository;
    @Mock
    private UserAuthService userAuthService;
    @BeforeEach
    void setUp() throws IOException {
        mockWebServer = new MockWebServer();
        mockWebServer.start();
    }
    @AfterEach
    void tearDown() throws IOException {
        mockWebServer.shutdown();
    }
}

각 객체 및 메서드에 대해서 알아보자

  • userGoogleAuthService : 테스트 대상 서비스 클래스
    • @InjectMocks를 통해 @Mock 객체들을 주입받음
    • @Spy를 통해서 필요한 경우 메서드를 스터빙할 수 있도록 함
  • userRepository, userAuthService : 테스트 대상 서비스들의 의존성
    • @Mock 어노테이션을 통해 필요한 의존성들에 대한 목 객체 생성
  • setUp : mockWebServer를 시작함
    • @BeforeEach를 통해 각 테스트 전에 실행됨
  • tearDown : mockWebserber를 종료함
    • @AfterEach를 통해 각 테스트 이후에 실행됨

getGoogleAccessToken 단위 테스트 코드 작성하기

테스트하려고 하는 메서드는 원래 아래 순서대로 동작한다.

  1. auth code를 디코딩
  2. 지정된 경로에 POST 요청을 통해 응답을 받아옴
  3. 응답에서 access token을 추출 및 반환

이를 토대로 아래와 같이 코드를 작성했다.

@ExtendWith(MockitoExtension.class)
class UserGoogleAuthServiceTest {
    private MockWebServer mockWebServer;
    @Spy
    @InjectMocks
    private UserGoogleAuthService userGoogleAuthService;
    @Mock
    private UserRepository userRepository;
    @Mock
    private UserAuthService userAuthService;
    @BeforeEach
    void setUp() throws IOException {
        mockWebServer = new MockWebServer();
        mockWebServer.start();
    }
    @AfterEach
    void tearDown() throws IOException {
        mockWebServer.shutdown();
    }
    @Test
    @DisplayName("구글 인증 토큰 발급 단위 테스트")
    void testGetGoogleAccessToken() throws JsonProcessingException, InterruptedException {
        // given
        String accessToken = "access_token";
        String mockUrl = "/google/auth/token";
        GoogleTokenResponseDto response = new GoogleTokenResponseDto(0, accessToken, "", "", "");
        ReflectionTestUtils.setField(userGoogleAuthService, "googleAccessTokenUri", mockWebServer.url(mockUrl).toString());
        mockWebServer.enqueue(new MockResponse()
                .setBody(new ObjectMapper().writeValueAsString(response))
                .setResponseCode(200)
                .addHeader("Content-Type", "application/json"));
        // when
        String result = userGoogleAuthService.getGoogleAccessToken(accessToken);
        RecordedRequest recordedRequest = mockWebServer.takeRequest();
        // then
        System.out.println(accessToken);
        System.out.println(recordedRequest.getMethod());
        System.out.println(recordedRequest.getPath());
        assertEquals(accessToken, result);
        assertEquals("POST", recordedRequest.getMethod());
        assertEquals(mockUrl, recordedRequest.getPath());
    }
}

테스트 코드를 given - when - then 순서로 살펴보자.

  • given : mockWebServer를 준비하고 필요한 변수들을 준비한다
    • accessToken은 응답으로 기대하는 access token 값이다.
    • mockUrl은 요청을 보낼 경로이고, mockWebServer.url을 통해 실제 경로로 변환한다.
  • when : 테스트 대상 메서드 실행 결과와 요청 정보를 저장한다
    • result에 테스트 대상 메서드 실행 결과를 저장한다
    • recordedRequest에 요청 정보를 저장한다
  • then : 요청 정보와 메서드 실행 결과 검증한다

mockWebServer에 환경 설정을 진행할 때 아래 주의 사항을 인지해야 한다.

  • 테스트 대상의 요청 경로 필드에 ReflectionTestUtils으로 임의의 값을 넣어줘야 한다.
  • mockWebServer가 구축해주는 테스트 서버는 localhost에서 동작하지 않는다.
    • 따라서 mockWebServer.url(요청경로).toString()을 통해서 요청 경로의 url을 받아왔다.

getGoogleUserInfo 단위 테스트 코드 작성하기

테스트 대상 메서드는 요청 정보를 구축하여 대상 경로로 GET 요청을 보내는 방식으로 동작한다.

 

마찬가지로 getGoogleAccessToken에서와 비슷하게 테스트 코드를 작성했다.

@ExtendWith(MockitoExtension.class)
class UserGoogleAuthServiceTest {
    private MockWebServer mockWebServer;
    @Spy
    @InjectMocks
    private UserGoogleAuthService userGoogleAuthService;
    @Mock
    private UserRepository userRepository;
    @Mock
    private UserAuthService userAuthService;
    @BeforeEach
    void setUp() throws IOException {
        mockWebServer = new MockWebServer();
        mockWebServer.start();
    }
    @AfterEach
    void tearDown() throws IOException {
        mockWebServer.shutdown();
    }
    @Test
    @DisplayName("구글 사용자 정보 발급 단위 테스트")
    void testGetGoogleUserInfo() throws JsonProcessingException, InterruptedException {
        //given
        String email = "userEmail", accessToken = "access_token";
        String mockUrl = "/google/auth/userinfo";
        GoogleUserInfoResponseDto response = GoogleUserInfoResponseDto.builder().email(email).build();
        ReflectionTestUtils.setField(userGoogleAuthService, "googleUserInfoUri", mockWebServer.url(mockUrl).toString());
        mockWebServer.enqueue(new MockResponse()
                .setResponseCode(200)
                .setHeader("Content-Type", "application/json")
                .setBody(new ObjectMapper().writeValueAsString(response)));
        //when
        GoogleUserInfoResponseDto result = userGoogleAuthService.getGoogleUserInfo(accessToken);
        RecordedRequest recordedRequest = mockWebServer.takeRequest();
        //then
        System.out.println(email);
        System.out.println(recordedRequest.getMethod());
        System.out.println(recordedRequest.getPath());
        assertEquals(email, result.getEmail());
        assertEquals("GET", recordedRequest.getMethod());
        assertEquals(mockUrl, recordedRequest.getPath());
    }
}

이전 테스트와 비슷하므로 given-when-then 순서로 간단하게 알아보자.

  • given : 테스트에 필요한 변수들과 mockWebServer 설정을 진행한다.
    • email과 accessToken은 응답의 기대값이다.
    • mockUrl은 요청을 보낼 경로이고, mockWebServer.url을 통해 실제 경로를 생성한다.
  • when : 실제 테스트 대상 메서드 실행 및 요청 정보 저장
  • then : 테스트 대상 메서드 결과와 요청 정보를 검증한다.

signIn 메서드에 대한 단위 테스트 코드 작성

마지막으로 signIn 메서드의 동작 방식을 알아보자.

  1. getGoogleAccessToken을 통해 access token을 받아옴
  2. getGoogleUserInfo를 통해 사용자 정보를 받아옴
  3. 해당 사용자가 DB에 존재하지 않으면 사용자를 저장함
  4. 사용자 로그인 인증정보 생성 및 반환

해당 메서드는 위 두 메서드가 필요하기에 스터빙을 통해 독립적인 테스트를 구축한다.

@ExtendWith(MockitoExtension.class)
class UserGoogleAuthServiceTest {
    private MockWebServer mockWebServer;
    @Spy
    @InjectMocks
    private UserGoogleAuthService userGoogleAuthService;
    @Mock
    private UserRepository userRepository;
    @Mock
    private UserAuthService userAuthService;
    @Test
    @DisplayName("구글 로그인 단위 테스트")
    void testGoogleLogin() {
        // given
        String email = "userEmail";
        UserAuthResponseDto expectedResult = UserAuthResponseDto.builder().email(email).build();
        GoogleUserInfoResponseDto expectedUserInfo = GoogleUserInfoResponseDto.builder().email(email).build();
        User savedUser = User.builder().email(email).build();
        // stub
        doReturn("access_token").when(userGoogleAuthService).getGoogleAccessToken(anyString());
        doReturn(expectedUserInfo).when(userGoogleAuthService).getGoogleUserInfo(anyString());
        when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(savedUser));
        when(userAuthService.generateResponseAndSaveToken(anyString(), any())).thenReturn(expectedResult);
        // when
        UserAuthResponseDto res = userGoogleAuthService.signIn("authCode");
        // then
        System.out.println(res.getEmail());
        assertEquals(expectedResult.getEmail(), res.getEmail());
    }
}

기존에 테스트 대상 클래스에 @Spy 어노테이션을 사용했기에, 상황에 따른 스터빙을 할 수 있었다.

 

마찬가지로 given-stub-when-then으로 코드를 알아보자.

  • given : 테스트에 필요한 변수와 객체를 준비
    • expectedResult : 테스트 대상 메서드의 기대값
    • expectedUserInfo : 요청을 통해 받아오는 사용자 정보의 기댓값
    • savedUser : DB에 저장되어 있는 사용자
  • stub : 외부 메서드에 대한 스터빙 진행
    • 테스트 클래스 내부의 메서드 스터빙
      • getGoogleAccessToken
      • getGoogleUserInfo
    • 외부 메서드 스터빙
      • userRepository.findByEmail
      • userAuthService.generateResponseAndSaveToken
  • when : 테스트 대상 실행 및 결과 저장
  • then : 테스트 결과 출력 및 검증

 

마무리 및 발생한 문제들

 

이번에는 @Spy, @InjectMocks, @Mock을 활용하여 상황에 따라 다른 mock 객체를 생성하여 테스트를 진행했다.

또한, MockWebServer를 통해서 테스트용 임시 서버를 구축했다.

 

테스트 중 아래 문제들이 발생했다.

테스트 중 발생한 문제

@InjectMocks에 @MockBean이 주입되지 않는 문제

  • @InjectMocks와 @MockBean을 혼합 사용하여 문제가 발생했다.
  • @MockBean은 Spring context에서 관리하므로 @InjectMocks로의 DI가 발생하지 않는다.
  • @InjectMocks 대신 @SpyBean을 사용하여 문제를 해결했다.
  • 위 테스트에서는 컨텍스트 로드가 불필요하기에 컨텍스트를 사용하지 않는 방향으로 테스트를 수정했다.

스터빙한 메서드가 실제 호출되는 문제

  • @Spy 어노테이션을 사용한 클래스의 특정 메서드를 스터빙한 상황
  • when().thenReturn() 으로 스터빙한 메서드가 테스트 시 호출되어 문제가 발생했다.
  • when().thenReturn()가 spy 객체의 실제 메서드 호출 후 mock 객체 리턴값을 반환하기에 발생한 문제였다
  • 이를 해결하기 위해 실제 메서드를 호출하지 않는 doReturn().when()을 사용하여 문제를 해결했다.