본문 바로가기
개발/Spring

[Test] 외부 API 테스트하기 (+ RestTemplateBuilder)

by baau 2023. 7. 6.

SWM에서 프로젝트를 진행하는 과정에서 카카오 도서 검색 API를 사용하여 도서를 검색해 오는 기능을 구현하고, 테스트 코드를 작성하는 과정에서 아래와 같은 이슈가 나타났다.

Unable to use auto-configured MockRestServiceServer since MockServerRestTemplateCustomizer has not been bound to a RestTemplate
java.lang.IllegalStateException: Unable to use auto-configured MockRestServiceServer since MockServerRestTemplateCustomizer has not been bound to a RestTemplate
	at org.springframework.util.Assert.state(Assert.java:76)
	at org.springframework.boot.test.autoconfigure.web.client.MockRestServiceServerAutoConfiguration$DeferredRequestExpectationManager.getDelegate(MockRestServiceServerAutoConfiguration.java:116)
	at org.springframework.boot.test.autoconfigure.web.client.MockRestServiceServerAutoConfiguration$DeferredRequestExpectationManager.expectRequest(MockRestServiceServerAutoConfiguration.java:88)
	at org.springframework.test.web.client.MockRestServiceServer.expect(MockRestServiceServer.java:107)
	at org.springframework.test.web.client.MockRestServiceServer.expect(MockRestServiceServer.java:92)

 

이슈 발생 과정

KakaoBookSearchApiService에서는 RestTemplate을 사용하여 카카오 도서 검색 API를 호출하도록 구현하였다. 코드는 아래와 같다.

@Service
public class KakaoBookSearchApiService implements PlatformBookSearchApiService {


    @Value("${kakao.api.key}")
    private String API_KEY;

    @Autowired
    private final RestTemplate restTemplate;

    private static final String API_URL_WITH_KEYWORD = "https://dapi.kakao.com/v3/search/book?query=%s&sort=%s&page=%d";
    private static final String API_URL_WITH_ISBN = "https://dapi.kakao.com/v3/search/book?target=isbn&query=%s&sort=%s&page=%d";
    private static final String REQUEST_NEXT_URL = "/books/search?keyword=%s&sort=%s&page=%d";
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String KAKAO_AUTHORIZATION_HEADER_PREFIX = "KakaoAK ";

    @Override
    public BookSearchResultResponse searchWithKeyword(String keyword, String sort, int page) {
        return searchBookFromKakaoApi(API_URL_WITH_KEYWORD, keyword, sort, page);
    }

    @Override
    public BookSearchResultResponse searchWithISBN(String isbn) {
        return searchBookFromKakaoApi(API_URL_WITH_ISBN, isbn, "accuracy", 1);
    }

    private BookSearchResultResponse searchBookFromKakaoApi(String baseUrl, String searchKeyword, String sort, int page) {
        HttpHeaders headers = new HttpHeaders();
        headers.add(AUTHORIZATION_HEADER, KAKAO_AUTHORIZATION_HEADER_PREFIX + API_KEY);
        headers.setContentType(MediaType.APPLICATION_JSON);

        HttpEntity<HttpHeaders> httpEntity = new HttpEntity<>(headers);

        String requestUri = String.format(baseUrl, searchKeyword, sort, page);
        ResponseEntity<KakaoBookSearchResponse> response = restTemplate.exchange(requestUri,
                HttpMethod.GET, httpEntity, KakaoBookSearchResponse.class);

        ...   
    }
}

위 Service 코드는 다른 Service 코드와 다르게 외부 API를 사용하기 때문에, @ExtendWith(MockitoExtension.class)만을 사용해서는 단위 테스트를 할 수 없다.

 

외부 API를 테스트 하기 위해서는 아래와 같이 @RestClientTest를 사용할 수 있다.

  • @RestClientTest는 외부 API를 사용할 때, Rest 클라이언트(카카오 API)를 쉽게 mock으로 대체하여 테스트를 할 수 있다.
  • MockRestServiceServer라는 임시 서버를 Bean으로 생성해 주기 때문에 주입받아 사용하여, 원하는 형태의 요청이 오면 지정된 값으로 응답을 줄 수 있다.
  • @SpringBootTest와 달리 최소한의 Context만 사용하여 테스트를 진행한다.
@RestClientTest(value = KakaoBookSearchApiService.class)
class KakaoBookSearchApiServiceTest {

    private static final String KAKAO_BOOK_SEARCH_API_URL = "https://dapi.kakao.com/v3/search/book";
    private static final String JSON_FILE_PATH = "src/test/resources/kakaoapi/";
    private static final String EXIST_ISBN = "9788980782970";
    private static final String NOT_EXIST_ISBN = "978898078297011";

    @Autowired
    private KakaoBookSearchApiService kakaoBookSearchApiService;

    @Autowired
    private MockRestServiceServer mockServer;

    @DisplayName("카카오 도서 검색의 결과는 최대 10권이며, 10권이 넘어가면 expectNextRequestUrl이 존재한다.")
    @Test
    void searchFromKakaoApiAndResultMoreThan10() throws IOException {

        // given
        String expectNextRequestUrl = "/books/search?keyword=docker&sort=accuracy&page=2";
        String expectResult = readJsonFile("resultMoreThan10FromKeyword.json");

        mockServer
                .expect(requestTo(KAKAO_BOOK_SEARCH_API_URL + "?query=docker&sort=accuracy&page=1"))
                .andRespond(withSuccess(expectResult, MediaType.APPLICATION_JSON));

        // when
        BookSearchResultResponse bookSearchResultResponse = kakaoBookSearchApiService
                .searchWithKeyword("docker", "accuracy", 1);

        // then
        assertAll(
                () -> assertThat(bookSearchResultResponse.getBookSearchResult().size()).isEqualTo(10),
                () -> assertThat(bookSearchResultResponse.getNextRequestUrl()).isEqualTo(expectNextRequestUrl)
        );
    }

    private String readJsonFile(String fileName) throws IOException {
        return new String(Files.readAllBytes(Paths.get(JSON_FILE_PATH + fileName)));
    }
}
  • @RestClientTest를 선언하고, 테스트하고 싶은 서비스 코드MockRestServiceServer를 주입받는다.
  •  mockServer.expect /. andRespond를 통해 어떤 요청이 들어올 때, 어떤 응답을 반환할지에 대해서 결정할 수 있다.

@RestClientTest와 MockRestServiceServer를 통해, 저의 목적대로 외부 API 클라이언트를 Mocking 하고 온전히 제가 작성한 kakaoBookSearchApiService에 대해서 단위 테스트를 진행할 수 있었다.

 

이슈 발생

하지만 아래와 같은 오류가 발생했다. 해당 오류는 MockServerRestTemplateCustomizer가 RestTemplate를 바인딩하지 못해 MockServiceServer를 자동 구성할 수 없어 발생한 오류이다.

 

이슈 해결 과정

MockServiceServer는 RestTemplate를 바인딩하지 못하니 직접 바인딩을 해줘야 한다.

아래와 같이 MockRestServiceServer.createServer를 통해서 RestTemplate을 직접 바인딩하여, 오류를 해결할 수 있다.

@Autowired
private RestTemplateBuilder restTemplateBuilder;

private MockRestServiceServer mockServer;

@BeforeEach
public void setup() {
    RestTemplate restTemplate = restTemplateBuilder.build();
    mockServer = MockRestServiceServer.createServer(restTemplate);
}

 

추가 기록 ( + RestTemplateBuilder 사용하기)

이슈와는 조금 다른 문제이지만, 기존 코드에서 개선한 부분이 있어서 추가적으로 기록하고자 한다.

기존 코드에서는 단순히 RestTemplate을 아래와 같이 빈 등록을 하여 사용하였다.

@Bean
public RestTemplate restTemplate() {
    return new RestTemplate();
}

하지만 위의 방법보다는 RestTemplateBuilder를 빈으로 등록하고, RestTemplateBuilder를 주입받아 RestTemplate을 생성하는 것이 더 권장하는 방법이라고 한다.

 

@Bean
public RestTemplateBuilder restTemplateBuilder() {
    RestTemplateBuilder builder = new RestTemplateBuilder();
    builder.setConnectTimeout(Duration.ofMillis(15000));
    builder.setReadTimeout(Duration.ofMillis(15000));
    return builder;
}

RestTemplateBuilder는 RestTemplate을 생성하는 빌더 역할을 하며, 인증 정보, 요청/응답 로깅, 커넥션 타임아웃 등을 다양한 구성 옵션을 쉽게 적용할 수 있다. RestTemplate을 직접 주입받아 사용하는 것보다 RestTemplate의 설정을 더 유연하고 일관성 있게 제공할 수 있다.

 

Service 코드에서는 아래와 같이 RestTemplate을 주입받을 수 있다.

private final RestTemplate restTemplate;

public KakaoBookSearchApiService(RestTemplateBuilder restTemplateBuilder) {
    this.restTemplate = restTemplateBuilder.build();
}