스프링 부트 테스트(2) - 외부 api 테스트

hyungjunn·2024년 6월 16일
0

지난 번에 스프링 부트 테스트(1) - 속도 비교에서와 같이 스프링 부트 테스트를 할 때와 목 테스트의 테스트 시간의 차이를 비교했다.

언뜻 보기에는 Mock 기반 테스트가 좋아 보인다. 하지만, 단점이 존재한다. 내 생각에 mock테스트의 단점은 다음과 같다.

  1. mock테스트는 말그대로 모의 테스트임으로 실제 환경과 차이가 생길 수 있다.
  2. 행위를 테스트하는 것이기 때문에 시나리오에 맞게 테스트를 작성해야 한다. 테스트를 하는데 다른 계층들도 아우러서 생각해야 하기 때문에 배보다 배꼽이 더 큰 상황이 생긴다.

따라서, 이것을 효과적으로 대처해주는 것이 springboot test slices다.

테스트는 테스트인데 slice?

무엇을 자르는거냐면 범위를 자르는 것이다. 내가 진짜 테스트하고자 하는 기능이 들어가있는 곳을 제외하고 잘라 딱 필요한 곳만 테스트할 수 있게 도와준다. 이것은 특히 외부 api를 연동했을 때의 기능을 검증할 때 굉장히 효과적이다.


비교를 위해 먼저 @SpringBootTest로 테스트한 결과를 나타내면 다음과 같다.

@SpringBootTest
class ApiConvertorTest {

    private ApiConvertor apiConvertor;

    @Autowired
    ApiConvertorTest(RestTemplate restTemplate, PublicDataApi publicDataApi) {
        this.apiConvertor = new ApiConvertor(restTemplate, publicDataApi);
    }

    @Test
    void _2024년_5월의_기준_근로_시간을_구하는_메서드를_검증하라() throws MalformedURLException, URISyntaxException {
        long numberOfStandardWorkingDays = apiConvertor.countNumberOfStandardWorkingDays(YearMonth.of(2024, 5));
        assertThat(numberOfStandardWorkingDays).isEqualTo(21L);
    }

}

테스트 시간을 5번 측정하니 결과는 다음과 같았다.

시도응답 시간
첫번째996ms
두번째951ms
세번째1065ms
네번째1134ms
다섯번째972ms
평균1023.6ms

이제 spring boot slice test 중의 하나인 @webMvcTest로 테스트를 해보고 측정해보자.

여기서 주목할 점은 우리는 countNumberOfStandardWorkingDays(..)를 테스트한다는 점이다. 즉, 2024년 5월의 기준근로일을 계산하는 로직을 테스트하는 것이기 때문에 실제값들을 공공데이터 api로 불러들어왔는지를 검증하진 않아도 된다.

이렇게 계산하는 로직만 테스트면 언제든지 통과할 수 있다는 장점이 있다. 네트워크에 종속되지 않고, 공공 api의 서버 에러를 피하면서 항상 테스트를 통과시킬 수 있다.

이 슬라이스 테스트를 실현해주기 위해 공공 데이터 api에 관련된 객체를 dependency injection 혹은 전략패턴의 로직을 활용할 수 있다.

원래의 로직은 다음과 같다.

@Component
public class ApiConvertor {

    private final RestTemplate restTemplate;
    private final PublicDataApi publicDataApi;

    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");

    public ApiConvertor(RestTemplate restTemplate, PublicDataApi publicDataApi) {
        this.restTemplate = restTemplate;
        this.publicDataApi = publicDataApi;
    }

    private List<HolidayResponse.Item> itemsOfResponse(YearMonth yearMonth) throws MalformedURLException, URISyntaxException {
        String solYear = String.valueOf(yearMonth.getYear());
   
        int month = yearMonth.getMonthValue();
        String solMonth = (month < 10) ? "0" + month : String.valueOf(month);
        
        // 바로 이 combineURL 부분을 테스트를 위한 로직과 실제 공공데이터 api 를 이용하는 로직으로
        // 나누어서 테스트를 성공적으로 만들 수 있다.
        String stringURL = publicDataApi.combineURL(solYear, solMonth);
        URL url = new URL(stringURL);
   
        HolidayResponse holidayResponse = restTemplate.getForObject(url.toURI(), HolidayResponse.class);
        List<HolidayResponse.Item> items = holidayResponse.getBody().getItems();
        return items;
    }

    // 테스트하려는 메서드
    public long countNumberOfStandardWorkingDays(YearMonth yearMonth) throws MalformedURLException, URISyntaxException {
        List<HolidayResponse.Item> items = itemsOfResponse(yearMonth);

        int lengthOfMonth = yearMonth.lengthOfMonth();
        long numberOfWeekends = WeekendCalculator.countNumberOfWeekends(yearMonth);
        long numberOfHolidays = items.size();
        long numberOfWeekDays = lengthOfMonth - numberOfWeekends;

        Set<LocalDate> holidays = convertToLocalDate(items);
        numberOfHolidays = minusDuplicateHolidays(numberOfHolidays, holidays);

        return numberOfWeekDays - numberOfHolidays;
    }

    private static long minusDuplicateHolidays(long numberOfHolidays, Set<LocalDate> holidays) {
        numberOfHolidays -= holidays.stream()
                .filter(WeekendCalculator::isWeekend)
                .count();
        return numberOfHolidays;
    }

    private static Set<LocalDate> convertToLocalDate(List<HolidayResponse.Item> items) {
        return items.stream()
                .map(item -> LocalDate.parse(item.getLocdate(), DATE_FORMATTER))
                .collect(toSet());
    }

}
String stringURL = publicDataApi.combineURL(solYear, solMonth);

이 부분이 메서드 안에 담겨져 있어 제어하지 못하게 되기 때문에 제어할 수 있게 공통 인터페이스를 만들고 테스트를 위한 구현체와 실제 운영 하는 구현체로 나눌 수 있다.

// 공통으로 하는 인터페이스
public interface ApiProperties {
    String combineURL(String solYear, String solMonth);
}

// 인터페이스를 구현하는 구현체 
// 실제 운용하는 구현체
@Configuration
@ConfigurationProperties(prefix = "public.data.api")
public class PublicDataApi implements ApiProperties {

    private String url;
    private String serviceKey;

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getServiceKey() {
        return serviceKey;
    }

    public void setServiceKey(String serviceKey) {
        this.serviceKey = serviceKey;
    }

    @Override
    public String combineURL(String solYear, String solMonth) {
        return this.url + "?serviceKey=" + this.serviceKey + "&solYear=" + solYear + "&solMonth=" + solMonth;
    }

}

// 인터페이스를 구현하는 구현테
// 테스트를 위한 구현체
public class TestApi implements ApiProperties {
    @Override
    public String combineURL(String solYear, String solMonth) {
        return "http://test-url.com";
    }
}

위에서 작성했던 ApiConvertor 클래스를 위의 로직을 이용해서 나타내보면 다음과 같다.

@Component
public class ApiConvertor {

    private final RestTemplate restTemplate;
    private final ApiProperties apiProperties;

    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
    
    // 생성자를 인터페이스로 구성하여 의존성을 주입해줄 수 있게 해준다.
    public ApiConvertor(RestTemplate restTemplate, ApiProperties apiProperties) {
        this.restTemplate = restTemplate;
        this.apiProperties = apiProperties;
    }

    private List<HolidayResponse.Item> itemsOfResponse(YearMonth yearMonth) throws MalformedURLException, URISyntaxException {
        String solYear = String.valueOf(yearMonth.getYear());

        int month = yearMonth.getMonthValue();
        String solMonth = (month < 10) ? "0" + month : String.valueOf(month);

        String stringURL = apiProperties.combineURL(solYear, solMonth);
        URL url = new URL(stringURL);

        HolidayResponse holidayResponse = restTemplate.getForObject(url.toURI(), HolidayResponse.class);
        List<HolidayResponse.Item> items = holidayResponse.getBody().getItems();
        return items;
    }

    public long countNumberOfStandardWorkingDays(YearMonth yearMonth) throws MalformedURLException, URISyntaxException {
        List<HolidayResponse.Item> items = itemsOfResponse(yearMonth);

        int lengthOfMonth = yearMonth.lengthOfMonth();
        long numberOfWeekends = WeekendCalculator.countNumberOfWeekends(yearMonth);
        long numberOfHolidays = items.size();
        long numberOfWeekDays = lengthOfMonth - numberOfWeekends;

        Set<LocalDate> holidays = convertToLocalDate(items);
        numberOfHolidays = minusDuplicateHolidays(numberOfHolidays, holidays);

        return numberOfWeekDays - numberOfHolidays;
    }

    private static long minusDuplicateHolidays(long numberOfHolidays, Set<LocalDate> holidays) {
        numberOfHolidays -= holidays.stream()
                .filter(WeekendCalculator::isWeekend)
                .count();
        return numberOfHolidays;
    }

    private static Set<LocalDate> convertToLocalDate(List<HolidayResponse.Item> items) {
        return items.stream()
                .map(item -> LocalDate.parse(item.getLocdate(), DATE_FORMATTER))
                .collect(toSet());
    }

}

그 다음 springboot slice test를 이용하여 테스트를 작성해주면 다음과 같다.

@WebMvcTest(ApiConvertor.class)
class ApiConvertor1Test {

    @MockBean
    private RestTemplate restTemplate;

    @MockBean
    private ApiProperties apiProperties;

    @Autowired
    private ApiConvertor apiConvertor;

    @Test
    void _2024년_5월의_기준_근로_시간을_구하는_메서드를_검증하라() throws MalformedURLException, URISyntaxException {
        ApiProperties fakeApiProperties = new TestApi();
        // 가짜 응답 추가
        HolidayResponse fakeResponse = new HolidayResponse();
        // fakeItems에 필요한 가짜 데이터 추가
        HolidayResponse.Body body = new HolidayResponse.Body();

        HolidayResponse.Item date_2024_05_05 = new HolidayResponse.Item();
        HolidayResponse.Item date_2024_05_06 = new HolidayResponse.Item();
        HolidayResponse.Item date_2024_05_15 = new HolidayResponse.Item();

        // 실제의 값들을 지정해줌으로써 언제든지 테스트가 성공하도록 한다
        date_2024_05_05.setLocDate("20240505");
        date_2024_05_06.setLocDate("20240506");
        date_2024_05_15.setLocDate("20240515");

        List<HolidayResponse.Item> fakeItems = List.of(date_2024_05_05, date_2024_05_06, date_2024_05_15);
        body.setItems(fakeItems);
        fakeResponse.setBody(body);

        when(restTemplate.getForObject(any(URI.class), eq(HolidayResponse.class)))
                .thenReturn(fakeResponse);

        ApiConvertor apiConvertor = new ApiConvertor(restTemplate, fakeApiProperties);
        long numberOfStandardWorkingDays = apiConvertor.countNumberOfStandardWorkingDays(YearMonth.of(2024, 5));

        assertThat(numberOfStandardWorkingDays).isEqualTo(21L);
    }
}

테스트 시간을 측정해보면 다음같은 결과가 나왔다.

시도응답 시간
첫번째165ms
두번째207ms
세번째162ms
네번째160ms
다섯번째169ms
평균172.6ms

비록 ms라는 아주 작은 단위이긴하지만, 5~6배가량 차이가 난다는 것을 알 수 있다. 그리고 이러한 결과는 나중에 더 큰 의의가 생길 수 있다.

프로젝트가 커짐에 따라 테스트 작성량도 많아지게 된다. 그에 따라 전체 테스트를 계속 체크해야되고 리팩터링을 할 때마다 수시로 테스트를 실행시켜야 한다. 이런 테스트 전략 하나하나가 쌓임으로써 생산성에 많은 차이가 생길 수 있다.

0개의 댓글