[WireMock] JUnit 테스트에서 외부 API 호출을 우아하게 Mocking하는 방법

 

외부 API 호출이 포함된 비즈니스 로직의 테스트

테스트를 작성하는 과정에서, 우리는 항상 특정 의존 모듈에 대하여 2가지 중 하나를 선택해야 합니다.

  1. 실제 모듈을 주입받아 사용
  2. 해당 모듈의 모의 객체(Mock)를 생성하여 사용

내부 모듈로부터 수행되는 비즈니스 로직인 경우 둘 중 어느 쪽을 택하든 테스트를 구성하는 데에 문제는 없습니다. 하지만 외부에서 구동되는 서버의 API를 호출하는 경우에는 이야기가 다릅니다.

1번 방법(실제 모듈 사용)을 택해 실제 API 호출이 이루어지는 방향으로 테스트 코드를 작성하게 되면 아래와 같은 문제가 발생할 수 있습니다.

  • 외부 API 서버의 상태에 의존 - 외부 API의 이용 가능 여부에 따라 테스트의 성패가 결정된다.
  • 에러 상황을 테스트하기 어려움 - 외부 API가 이용 불가능한 상태(5xx status code)에 대한 테스트를 수행하기 어려워진다.
  • 비용 발생 가능 - 호출 당 비용을 지불하는 API의 경우 테스트 과정에서 청구되는 비용이 발생할 수 있다.

이러한 이유로 외부 API의 호출을 포함한 모듈은 모의 객체로 대체하여 사용하는 2번 방법을 택하는 것이 불가피해보입니다. 그렇다면 외부 API를 호출하는 모듈에 @Mock 어노테이션을 달아줌으로써 문제를 해결하면 완벽할까요? 하지만 이 경우에는 통합 테스트에서 모의 객체를 주입받아 사용한다라는 찝찝함이 남게 됩니다. 외부 API 호출을 mocking하고자 전체 모듈을 mocking했다가, 해당 모듈 내 다른 부분에서 발생하는 문제를 발견하지 못하게 될지도 모르기 때문이죠.

이 문제를 해결하는 더 나은 방법 중 한 가지는, 외부 API의 응답 자체를 mocking하는 것입니다. 이렇게 하면 외부 API 호출을 포함한 모듈 자체는 실제의 것을 주입받음으로써 응답에 따른 처리 절차 또한 무사히 테스트의 영역에 포함할 수 있게 됩니다.

이를 위한 훌륭한 도구 중 한 가지로, 오픈 소스 도구인 WireMock이 있습니다.

 

 

WireMock

 

WireMock - flexible, open source API mocking

WireMock is a tool for building mock APIs. API mocking enables you build stable, predictable development environments when the APIs you depend on are unreliable or don’t exist.

wiremock.org

WireMock은 API mocking test를 위한 오픈 소스 라이브러리입니다. 2011년 Java 언어에 대한 지원을 시작으로 현재에는 많은 프로그래밍 언어에 대해 기능을 지원하고 있다고 합니다.

이번 글에서는 JUnit5와 함께 Java Spring 테스트를 작성하는 과정에서 외부 API 호출을 mocking하는 간단한 예제들을 소개해보고자 합니다. (해당 글은 JUnit5를 기반으로 외부 API 로직을 테스트하는 코드의 작성 기법에 대해 다루는 것이 목적이므로, 글 내에서 사용되는 JUnit5의 기본 문법에 대한 설명은 생략합니다.)

 

소개할 예제는 다음과 같습니다.

  1. 기본적인 API 응답 테스트
  2. 시나리오에 따른 응답 테스트
  3. 호출에 대한 검증

 

 

WireMock 사용 사례

외부 API 호출을 포함한 모듈

테스트하고자 하는 모듈의 구성은 아래와 같습니다.

public class ExternalApiService {

    private final ObjectMapper objectMapper;

    private final String requestUrl;

    public ExternalApiService(ObjectMapper objectMapper, String requestUrl) {
        this.objectMapper = objectMapper;
        this.requestUrl = requestUrl;
    }

    public String getData() {
        try {
            int MAX_RETRY = 3;
            for (int i = 0; i < MAX_RETRY; i++) {
                ExternalApiResponse response = request();
                // 200대 응답을 받으면 바로 결과 데이터를 반환
                if (is2xxStatusCode(response.getStatusCode())) {
                    return response.getResult();
                }
                // 500대 응답을 받으면 3회까지 재시도
                if (is5xxStatusCode(response.getStatusCode())) {
                    System.out.printf("외부 API 서버 응답 오류: %d (재시도 %d/%d)"
                            .formatted(response.getStatusCode(), i, MAX_RETRY));
                    // 재시도 전 1초 대기
                    waitBeforeRetry(1000);
                }
            }
            return null;
        } catch (Exception e) {
            System.err.println("외부 API 요청 수행 중 오류 발생: " + e.getMessage());
            e.printStackTrace();
        }
        return null;
    }

    private ExternalApiResponse request() throws URISyntaxException, IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(new URI(requestUrl))
                .header("Content-Type", "application/json")
                .GET()
                .build();
        try (HttpClient client = HttpClient.newHttpClient()) {
            HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
            ExternalApiResponse result = objectMapper.readValue(response.body(), ExternalApiResponse.class);
            result.setStatusCode(response.statusCode());
            return result;
        }
    }

    private boolean is2xxStatusCode(int statusCode) {
        return statusCode / 100 == 2;
    }

    private boolean is5xxStatusCode(int statusCode) {
        return statusCode / 100 == 5;
    }

    private void waitBeforeRetry(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            System.err.println("외부 API 재시도를 위한 대기 중 문제 발생: " + e.getMessage());
        }
    }

}

의존성

해당 프로젝트는 Gradle(Kotlin), Java 21, Spring Boot 3.2로 작성되었으며 dependency는 아래와 같습니다.

dependencies {
    implementation("com.fasterxml.jackson.core:jackson-databind:2.16.1")

    testImplementation("org.junit.jupiter:junit-jupiter-engine:5.10.1")
    testImplementation("org.mockito:mockito-junit-jupiter:5.8.0")
    testImplementation("org.wiremock:wiremock:3.3.1")
}

WireMock 세팅

먼저, API 응답을 mocking할 WireMock 서버를 생성 및 관리할 Extension을 선언합니다.

@RegisterExtension
static WireMockExtension wireMockExtension = WireMockExtension
        .newInstance()
        .options(wireMockConfig().dynamicPort().dynamicHttpsPort())
        .build();

그리고 WireMock 서버의 host 주소를 기반으로 외부 API 요청의 URI를 재구성합니다.

저는 WireMock 서버가 랜덤 포트에서 동작하도록 위에서 설정했기 때문에 아래와 같이 실제 서버 포트를 가져와 host를 구성해줍니다.

final String EXTERNAL_API_URI = "/data";

@BeforeEach
void setUp() {
    String requestUrl = "http://localhost:%s".formatted(wireMockExtension.getPort()) + EXTERNAL_API_URI;
    service = new ExternalApiService(objectMapper, requestUrl);
}

1. 기본적인 API 응답 테스트

API 응답은 크게 성공과 실패로 나눌 수 있습니다.

WireMock을 이용하면 응답의 상태 코드와 응답 바디를 mocking할 수 있으므로, 아래와 같이 테스트를 작성할 수 있습니다.

@Test
void request_success() throws JsonProcessingException {
    // given
    String expectedData = "data of external API";
    Map<String, String> response = Map.of(
            "id", "349851320",
            "result", expectedData
    );
    wireMockExtension.stubFor(get(WireMock.urlPathEqualTo(EXTERNAL_API_URI))
            .willReturn(aResponse()
                    .withStatus(200)
                    .withBody(objectMapper.writeValueAsBytes(response))));

    // when
    String data = service.getData();

    // then
    assertEquals(expectedData, data);
}
@Test
void request_fail() throws JsonProcessingException {
    // given
    Map<String, String> errorResponse = Map.of(
            "error", "Server temporary unavailable."
    );
    wireMockExtension.stubFor(get(WireMock.urlPathEqualTo(EXTERNAL_API_URI))
            .willReturn(aResponse()
                    .withStatus(500)
                    .withBody(objectMapper.writeValueAsBytes(errorResponse))));

    // when
    String data = service.getData();

    // then
    assertNull(data);
}

성공했을 때에는 결과 데이터를 검증하고, 실패했을 때에는 null을 반환하도록 의도되었으므로 이를 검증합니다.

2. 시나리오에 따른 응답 테스트

1번에서는 성공과 실패에 대한 간단한 테스트 사례를 살펴보았는데요.

외부 API를 이용하는 경우, 일시적으로 외부 API 서버가 이용 불가능해 호출에 성공하지 못하는 경우도 발생할 수 있습니다. 1초의 서비스 장애에도 운 나쁘게 해당 타이밍에 요청을 보내게 되면, 5xx 상태 코드의 결과를 받게 될 수 있죠.

이러한 경우를 대비하여 외부 API 호출 로직에는 대부분 재시도(Retry)가 포함됩니다. 그렇다면 이 재시도를 테스트하고 싶다면 어떻게 해야 할까요?

재시도가 성공하는 경우의 조건은 동일한 조건으로 동일한 API를 호출했을 때 다른 응답을 받는 것입니다. WireMock은 이를 테스트하기 위해 시나리오(Scenario)라는 옵션을 제공합니다.

아래는 서버의 일시적 장애로 API 호출에 실패했다가, 재시도에 성공하여 응답을 받아오는 사례에 대한 테스트입니다.

@Test
void request_retry_success() throws JsonProcessingException {
    // given
    String expectedData = "data of external API";
    Map<String, String> errorResponse = Map.of(
            "error", "Server temporary unavailable."
    );
    Map<String, String> successResponse = Map.of(
            "id", "349851320",
            "result", expectedData
    );
    wireMockExtension.stubFor(get(WireMock.urlPathEqualTo(EXTERNAL_API_URI))
            .inScenario("External server temporary unavailable")
            .whenScenarioStateIs(STARTED)
            .willReturn(aResponse()
                    .withStatus(500)
                    .withBody(objectMapper.writeValueAsBytes(errorResponse)))
            .willSetStateTo("External server becomes available again"));
    wireMockExtension.stubFor(get(WireMock.urlPathEqualTo(EXTERNAL_API_URI))
            .inScenario("External server temporary unavailable")
            .whenScenarioStateIs("External server becomes available again")
            .willReturn(aResponse()
                    .withStatus(200)
                    .withBody(objectMapper.writeValueAsBytes(successResponse))));

    // when
    String data = service.getData();

    // then
    assertEquals(expectedData, data);
}

같은 시나리오로 묶인 stub들은 상태 전이에 따라 같은 호출에도 다른 응답을 제공하게 됩니다.

최초 상태인 STARTED에서는 서버가 일시적으로 이용 불가능해 5xx 응답을 제공했지만, 해당 stub이 호출된 후 2xx 응답을 제공하는 stub이 활성화되도록 state가 변경되었기 때문에, 재시도에서는 성공적으로 응답을 받게 됩니다. 만약 재시도 코드가 없다면, 해당 테스트는 실패하게 되겠죠.

3. 호출에 대한 검증

또한, wireMockExtension에는 특정 패턴의 요청이 몇 번 호출되었는지도 기록됩니다.

이 기록을 통해 요청이 원하는 횟수만큼 이루어지는지도 테스트의 then 절에서 검증하는 편이 좋습니다.

해당 서비스 모듈은 총 3회까지의 요청을 시도하고 있으므로, 아래와 같은 횟수 검증문을 추가할 수 있습니다.

// 바로 성공
wireMockExtension.verify(1, getRequestedFor(urlPathEqualTo(EXTERNAL_API_URI)));

// 에러에 의한 최종 실패
int MAX_RETRY = 3;
wireMockExtension.verify(MAX_RETRY, getRequestedFor(urlPathEqualTo(EXTERNAL_API_URI)));

// 1회 재시도 후 성공
wireMockExtension.verify(2, getRequestedFor(urlPathEqualTo(EXTERNAL_API_URI)));

 

 

결과

커버리지

위와 같은 테스트를 통해, 외부 API의 호출부에 대해 아래와 같은 코드 커버리지를 달성했습니다.

만약 해당 모듈 자체를 mocking해서 외부 API를 mocking하려고 했다면 위 부분 코드에 대한 테스트 커버리지를 충족할 수 없었겠죠.

이외에도 다양한 사례가 있습니다!

위에서 소개한 3가지의 사례는 제가 사용한 외부 API의 특성을 고려하여 테스트를 작성하는 과정에서 사용하게 된 일부 기능들입니다.

동일한 path에 대해 priority를 지정하여 제공될 mock result를 선정할 수도 있고, 요청 데이터의 특성에 따라 결과를 달리할 수도 있고, 이외에도 다양한 기능이 제공됩니다.

더 자세한 이용법은 공식 문서를 참조해주시고, 좋은 사용 사례가 있다면 저에게도 소개해주세요.

 

 

Code

해당 글에서 다룬 내용은 모두 아래 GitHub 저장소에 있습니다. 틀린 점에 대한 지적 및 피드백은 환영합니다.

 

GitHub - hyh1016/wiremock-playground: Repository containing sample code using wiremock, written in java & junit5

Repository containing sample code using wiremock, written in java & junit5 - GitHub - hyh1016/wiremock-playground: Repository containing sample code using wiremock, written in java & junit5

github.com