RestTemplate 모킹과 단위 테스트

민씨·2024년 1월 15일
0
post-custom-banner

개요

스마일게이트사의 로스트아크 Open API를 활용하여 토이 프로젝트를 진행하던 중, RestTemplate를 이용한 API 요청이 429 응답 코드로 실패하는 문제가 발생하였습니다.

트러블 슈팅 중 공식 문서를 확인해 보니, 분당 100회 요청 제한이 원인이었습니다.

Lostark OpenAPI Developer Portal

Clients are limited to fire 100 requests per a minute. Exceeding the minute quota results in 429 response until we reset the quota. We automatically renew the quota every minute, so you can expect your application would work after a minute once you hit the limit.

이러한 제한은 아래의 의문을 들게 하였습니다.

  • 그렇다면 테스트가 101개가 있다면 어떻게 처리해야 하는 것이지?
  • 100개의 테스트가 끝나면 1분을 대기하고 1개가 시작되길 기다려야 하나?
  • 만약 정책이 바뀌어서 대기 시간이 10분으로 늘어나면? 테스트가 1만개면?

이로 인해 제가 하던 테스트는 '단위 테스트'가 아니라 외부 API에 의존하는 '통합 테스트'였다는 것을 깨달았습니다.

이번 포스팅에서는 RestTempalte을 Mocking하여 단위 테스트를 어떻게 구성했는지 공유해 보고자 합니다.

문제의 테스트

기존의 서비스 코드는 다음과 같았습니다.

@Slf4j
@Service
public class LostArkApiService {

    @Value("${lost-ark-api-url}")
    private String lostArkApiUrl;

    @Value("${lost-ark-api-token}")
    private String lostArkApiToken;

    private static final String BEARER = "Bearer ";

    private final RestTemplate restTemplate;

    private final ObjectMapper objectMapper;

    public LostArkApiService(RestTemplate restTemplate, ObjectMapper objectMapper) {
        this.restTemplate = restTemplate;
        this.objectMapper = objectMapper;
    }

    public ProfileDto getProfiles(String characterName) {
        String url = UriComponentsBuilder.fromHttpUrl(this.lostArkApiUrl)
                .path(characterName)
                .path("/profiles")
                .build()
                .toUriString();

        HttpHeaders headers = new HttpHeaders();
        headers.set(HttpHeaders.AUTHORIZATION, BEARER + this.lostArkApiToken);

        HttpEntity<String> httpEntity = new HttpEntity<>(headers);
        String body = this.restTemplate.exchange(url, HttpMethod.GET, httpEntity, String.class).getBody();
        if (Objects.equals(body, "null")) {
            throw new CharacterNotFoundException(characterName + "에 해당하는 캐릭터가 없습니다.");
        }

        try {
            return this.objectMapper.readValue(body, ProfileDto.class);
        } catch (JsonProcessingException e) {
            log.error("JsonProcessingException", e);
            return null;
        }
    }
}

서비스 코드의 흐름은 다음과 같습니다.

  1. characterName을 이용하여 url을 생성한다
  2. Headers 객체에 토큰을 담는다.
  3. HttpEntity 객체를 생성한다.
  4. RestTemplate를 이용하여 로스트아크 Open API에 GET 요청을 보낸뒤 응답을 받는다.
  5. 응답 String을 파싱한 뒤 반환한다.

처음 작성했던 위 서비스 코드에 대한 테스트 코드 입니다.

@SpringBootTest
@ActiveProfiles("test")
class LostArkApiServiceTest {

    @Autowired
    LostArkApiService lostArkApiService;

    @DisplayName("프로필 조회에 성공하는 테스트")
    @Test
    void getProfiles() {
        String characterName = "성공하는_아이디";
    
        // given
        ProfileDto profileDto = this.lostArkApiService.getProfiles(characterName);

        // when
        int length = profileDto.getStats().toArray().length;

        // then
        assertThat(length).isEqualTo(8);
    }

    @DisplayName("프로필 조회에 실패하는 테스트")
    @Test
    void getProfilesFail() {
        // given
        String characterName = "실패하는_아이디";

        // when & then
        assertThatThrownBy(() -> this.lostArkApiService.getProfiles(characterName))
                .isInstanceOf(CharacterNotFoundException.class)
                .hasMessage(characterName + "에 해당하는 캐릭터가 없습니다.");
    }
}

위 테스트 코드를 작성하고 초록불이 들어오는 것을 확인한 뒤 뿌듯했으나 문제점이 있었습니다.

  • 로스트아크는 매 주 수요일마다 점검을 하는데 그 때는 테스트가 실패한다.
  • 외부 서버에 영향을 받는 테스트를 작성하는 것이 옳은가?

로 부터 의문이 들기 시작하였고, 결국 위와 같은 테스트 방식은 올바르지 않다는 것을 알게 되었습니다.

개선된 테스트

이 문제를 해결하기 위해 @MockBean 어노테이션을 사용하여 RestTemplate을 Mocking하는 방법으로 개선하였습니다.

서비스 코드의 흐름 중 외부 서비스에 의존하는

RestTemplate를 이용하여 로스트아크 Open API에 GET 요청을 보낸뒤 응답을 받는다.

부분을 whenthen을 이용하여 직접 제어할 수 있게 변경하였습니다.

개선된 코드는 다음과 같습니다.

@SpringBootTest
@ActiveProfiles("test")
class LostArkApiServiceMockTest {

    @Autowired
    LostArkApiService lostArkApiService;

    @MockBean
    RestTemplate restTemplate;

    String path = "src/test/java/me/minkh/app/";

    @DisplayName("프로필 조회에 성공하는 테스트")
    @Test
    void getProfiles() throws IOException {
        // given
        String characterName = "성공하는_아이디";
        String profile = new String(Files.readAllBytes(Paths.get(path + "profile.json")));

        // when
        when(restTemplate.exchange(anyString(), any(), any(), eq(String.class)))
                 .thenReturn(ResponseEntity.ok(profile));
        LostArkProfilesResponse lostArkProfilesResponse = this.lostArkApiService.getProfiles(characterName);

        // then
        assertThat(lostArkProfilesResponse.getStats().size()).isEqualTo(8);
    }

핵심은 이 부분입니다.

String profile = new String(Files.readAllBytes(Paths.get(path + "profile.json")));

when(restTemplate.exchange(anyString(), any(), any(), eq(String.class)))
                 .thenReturn(ResponseEntity.ok(profile));

RestTemplate를 이용하여 exchange 메서드를 호출하면 응답값으로 상태코드 200과 메시지 바디로 profile를 반환하도록 모킹해 주었습니다.

문제점에서 걱정한, 외부 서버에 의존하는 방식이 아니기 때문에 1만번을 호출해도 상관이 없고 로스트아크 서버가 다운되도 테스트에는 영향이 없습니다.

또한 이렇게 개선된 코드는 로스트아크 Open API의 다양한 상태 코드에도 대응할 수 있게 해줍니다.

@DisplayName("인증 토큰이 올바르지 않을 때, 실패하는 테스트")
@Test
void getProfiles401() {
    // given
    String characterName = "성공하는_아이디";

    // when & then
    when(restTemplate.exchange(anyString(), any(), any(), eq(String.class)))
              .thenThrow(new HttpClientErrorException(HttpStatus.UNAUTHORIZED));

    assertThatThrownBy(() -> this.lostArkApiService.getProfiles(characterName))
            .isInstanceOf(HttpClientErrorException.class);
}

이와 같이, 다양한 상태 코드에 대한 테스트를 수행함으로써 전역 예외 처리가 제대로 작동하는지 확인할 수도 있습니다.

마치며

이번 경험을 통해 '단위 테스트'와 '통합 테스트'의 차이점을 명확히 이해할 수 있었습니다.

예전에는 사실 모킹이라는 것이 단순히 "내가 A라고 설정하면 B라고 반환되게 해줘"라고 하면 무슨 소용이지? 라는 생각을 가지고 있었는데요.

비즈니스 로직에서 일부 외부 환경에 의존할 수 밖에 없는 부분은 잠시 모킹하고 다른 부분을 검증하기 위해 꼭 필요한 과정이라는 것을 알게된 소중한 경험이었습니다.

profile
進取
post-custom-banner

0개의 댓글