외부 API를 어떻게 테스트 할 것인가?

kyle·2020년 9월 13일
19

우아한 테크 마켓

목록 보기
3/6
post-thumbnail

안녕하세요. 이번 포스팅에서는 외부 API에 의존적인 로직을 어떻게 테스트했는지에 대해서 이야기 해보고자 합니다. 다른 좋은 방법이 있다면 댓글로 알려주시면 감사하겠습니다! 저는 Mock Server를 활용하여 외부 서버를 Mocking 하는 방식으로 진행하였습니다.
모든 코드는 여기서 확인하실 수 있습니다! 😊

외부 API

어플리케이션을 개발 하다보면 외부 API를 사용해야 할 일이 많습니다. 저의 경우는 카카오 로그인을 사용하여 어플의 인증(Authentication)을 구현하고 있었습니다. 저번 포스팅에서 얘기한 것처럼 카카오로부터 토큰을 받아오고 유저 정보를 받아 오기 위해, 외부 API 사용 로직이 포함되는데요!

카카오 로그인을 테스트할 때 우리는 자바 코드를 통해서 사용자의 아이디 비밀번호를 입력하고, Code를 받아올 수 없습니다. Code를 받을 수 없으니 당연히 토큰도 받을 수 없고 토큰을 받을 수 없으니 사용자 정보도 받아올 수 없겠죠?

그렇다면 백엔드 개발자는 프론트 없이 자신이 카카오 로그인 구현했어 라고 말할 수 없는것일까요?

외부 서버 Mocking

외부 서버는 우리가 제어할 수 있는 대상이 아니기 때문에 Mocking하고 우리의 로직만을 테스트 한다면 완벽하게 동작해 라고는 말할 수 없지만 카카오 API가 문제 없으면 동작할거야 ****라고 말할 순 있을 것 같아요. 간단하게 외부 서버를 Mocking하는 것의 의미와 장점을 알아보고 제가 구현한 코드를 보여 드리겠습니다.

외부 서버를 Mocking함으로써 얻을 수 있는 좋은 점

  • 외부 서버에 종속적인 API를 외부 서버와 나의 비즈니스 로직으로 분리함으로써 나의 비즈니스 로직을 테스트 할 수 있게 된다.
  • API 스펙만 확립되어 있다면 다른 부서에서 서로 독립적인 개발이 가능해진다.
  • 외부 API 혹은 네트워크가 문제가 있더라도 외부 요인과 관계 없이 테스트가 가능해진다.

일반적으로 Controller 단위 테스트에서 Service를 Mocking하고 테스트를 진행하듯 외부 서버도 우리의 로직에서는 Mocking하고 우리의 로직만 테스트하고자 하는 맥락이에요. 결국 의존적인 오브젝트들을 대역을 사용함으로써 대역이 정상적이라는 가정하에 핵심 로직을 테스트하고자 한다는 관점이에요.

구현 - 전체 코드

그렇다면 저는 어떻게 구현 했는지 간단하게 설명하겠습니다. 저는 카카오 로그인과 관련된 LoginService 오브젝트가 WebClient를 통해 카카오 서버로 요청을 보냅니다. LoginService의 테스트 코드는 아래와 같습니다!

WebClient Mocking

@ExtendWith(MockitoExtension.class)
class KakaoAPIServiceTest {
    private KakaoAPIService kakaoAPIService;

    @Mock
    private JwtTokenProvider jwtTokenProvider;

    private ObjectMapper objectMapper;
    private MockWebServer mockWebServer;
    private String mockServerUrl;
    private Dispatcher dispatcher;
    private MockResponse tokenResponse;
    private MockResponse userResponse;

    @BeforeEach
    void setUp() throws JsonProcessingException {
        objectMapper = new ObjectMapper();
        mockWebServer = new MockWebServer();
        mockServerUrl = mockWebServer.url("/").toString();
        kakaoAPIService = new KakaoAPIService(mockServerUrl, mockServerUrl,
						SERVER_URI, CLIENT_ID_VALUE,
            CLIENT_SECRET_VALUE, GRANT_TYPE_VALUE); // 1 

        tokenResponse = new MockResponse() // 2
            .addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
            .setResponseCode(HttpStatus.OK.value())
            .setBody(objectMapper.writeValueAsString(createMockKakaoTokenResponse()));

        userResponse = new MockResponse() // 3
            .addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
            .setResponseCode(HttpStatus.OK.value())
            .setBody(objectMapper.writeValueAsString(createMockKakaoUserResponse()));

        dispatcher = new Dispatcher() { // 4
            @NotNull
            @Override
            public MockResponse dispatch(RecordedRequest request) {
                if (request.getPath().contains(OAUTH_TOKEN_PATH)) {
                    return tokenResponse;
                }
                if (request.getPath().contains(USER_INFO_PATH)) {
                    return userResponse;
                }
                return new MockResponse().setResponseCode(404);
            }
        };
        mockWebServer.setDispatcher(dispatcher); 
    }

    @AfterEach
    void shutdown() throws IOException {
        mockWebServer.shutdown();
    }

    @DisplayName("Redirect 될 토큰 페이지 Url을 리턴한다.")
    @Test
    void createTokenUrlTest() {
        when(jwtTokenProvider.createToken(KAKAO_ID)).thenReturn(ACCESS_TOKEN);

        String url = new DefaultUriBuilderFactory().builder()
            .path(SERVER_URI + LOGIN_CHECK_PATH)
            .queryParam(ACCESS_TOKEN, ACCESS_TOKEN)
            .build().toString();

        assertThat(kakaoAPIService.createTokenUrl(
            JwtTokenResponse.of(jwtTokenProvider.createToken(KAKAO_ID))))
            .isEqualTo(url);  // 5
    }

    @DisplayName("카카오 서버에 요청을 해 Mono 토큰 정보를 받아온다.")
    @Test
    void fetchOAuthTokenTest() {
        StepVerifier.create(kakaoAPIService.fetchOAuthToken(CODE_VALUE))
            .consumeNextWith(body -> assertThat(body).isEqualToComparingFieldByField(createMockKakaoTokenResponse()))
            .verifyComplete();  // 6
    }

		@DisplayName("카카오 서버에 요청을 해 Mono UserInfo를 받아온다.")
    @Test
    void fetchOAuthUserInfoTest() {
        StepVerifier.create(kakaoAPIService.fetchUserInfo(createMockKakaoTokenResponse()))
            .consumeNextWith(body -> assertThat(body).isEqualToComparingFieldByField(createMockKakaoUserResponse()))
            .verifyComplete();  // 7
    }
}
  1. MockServer를 생성하고, 테스트하는 서비스에 MockServer와 관련된 설정 정보를 생성자로 할당함으로써 MockServer로 요청을 하도록 변경합니다.
  2. 토큰과 관련된 응답을 설정합니다. 외부 서버를 대역을 사용하면 응답도 자신이 원하는 형태로 지정하여 줄 수 있습니다.
  3. 카카오 사용자 정보를 받아오는 부분도 자신이 원하는 형태로 응답을 설정합니다.
  4. 서버에서 요청을 받고 요청을 요청을 처리 할 Dispatcher를 생성하여, 원하는 URL 및 설정을 추가합니다. (코드에는 URL만 설정되어 있으나, body, header 등 다양한 설정을 통해 더욱 구체적으로 설정할 수 있습니다)
  5. 로그인이 성공적으로 이루어진 경우 리다이랙트를 통해 토큰을 정상 반환합니다 - 이 부분은 외부 서버와 관련이 없는 테스트입니다.
  6. 설정한 외부 서버(카카오 서버 MOCK)에 요청을 보내 토큰과 관련된 정보를 받아오고 해당 토큰을 검증합니다.
  7. 설정한 외부 서버(카카오 서버 MOCK)에 요청을 보내 토큰을 보내어 관련된 사용자 정보를 받아오고 해당 사용자의 정보를 검증합니다.

참고

  • 위와 같은 형태로 요청을 테스트할 수 도 있고 WebClient & RestTemplate 등을 사용하여 MockServer를 테스트할 수도 있습니다. 다만 저는 서비스 메소드를 테스트하는 것이었기 때문에 해당 메소드에 설정 정보를 변경하고 해당 메소드를 실행하는 방식으로 진행하였습니다.
  • 목 서버를 생성하는 방식부터, 이를 검증하는 방식까지 다양한 방법이 존재합니다. 다른 방법을 찾아보시고 사용하는 것도 좋을 것 같습니다.
  • 기억보단 기록을 - 외부 API 테스트 에 보면 외부 서버에서 반환하는 DTO에 대해서도 테스트를 함으로써 불완전한 테스트를 조금 더 보완할 수 있습니다. 다만 해당 글은 시간과 관련된 부분이라 포맷팅을 해야함이 어느정도 예상가능 했지만 저와 같이 서버만 개발하는 환경에서는 예상하기 어려운 것 같습니다.(카카오 로그인 처럼 실제 요청을 못 보내는 경우) DTO 변환 로직도 테스트 하는 것이 바람직 할 것 같습니다.

결론

외부 서버를 Mocking하는 것은 사실 의존하고 있는 오브젝트를 대역을 사용하는 것과 비슷한 것 같습니다. 의존하는 것에 대한 부분을 대역을 사용하고 자신의 고유 메소드를 테스트한다는 점에서요!

카카오 로그인을 완벽하게 구현했다라고 말할 순 없지만, 위와 같은 테스트를 추가함으로써 외부 API가 정상적으로 동작한다면 카카오 로그인은 정상 동작합니다. 라고 말할 수 있는 것만으로도 엄청난 가치 갖지 않을까 싶습니다! 감사합니다. (설정 정보 같은 걸 잘 못 적으면 여전히 안되겠지만요.. 반환하는 DTO나 요청 관련된 부분도 동일하게 테스트 해야 합니다!)

profile
오늘 하루도 좋은 하루 보내세요! 혹시 시간 괜찮으시면 악플이라도 하나,, 어떠세요?🙇‍♂️

4개의 댓글

comment-user-thumbnail
2021년 6월 19일

감사합니다!

답글 달기
comment-user-thumbnail
2022년 8월 23일

잘 보고 갑니다 !

답글 달기
comment-user-thumbnail
2022년 11월 4일

오 목?

1개의 답글