Spring WireMock API 테스트 심화

WireMock이란?

WireMock은 HTTP 기반 외부 API를 모킹하는 라이브러리입니다. 실제 외부 서비스 없이 HTTP 요청/응답을 시뮬레이션하여 통합 테스트를 안정적으로 수행할 수 있습니다. Spring Boot 3.x에서는 spring-cloud-contract-wiremock을 통해 자동 설정을 지원하며, 네트워크 장애, 타임아웃, 지연 응답 등 다양한 시나리오를 테스트할 수 있습니다.

테스트 방법 장점 단점
Mockito Mock 빠름, 단순 HTTP 레벨 검증 불가
WireMock 실제 HTTP 통신 검증 서버 기동 필요
Testcontainers 실제 서비스 실행 느림, 리소스 많음
실제 API 가장 현실적 불안정, 비용, 속도

의존성 설정

// build.gradle.kts
dependencies {
    testImplementation("org.wiremock:wiremock-standalone:3.5.4")
    // 또는 Spring Cloud 통합
    testImplementation("org.springframework.cloud:spring-cloud-contract-wiremock:4.1.2")
}

기본 사용: JUnit 5 확장

WireMock의 JUnit 5 확장을 사용하면 테스트마다 자동으로 서버가 시작/종료됩니다.

@SpringBootTest
@WireMockTest(httpPort = 8089)
class PaymentGatewayClientTest {

    @Autowired
    private PaymentGatewayClient client;

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("payment.api.base-url", () -> "http://localhost:8089");
    }

    @Test
    void 결제_성공_응답을_올바르게_파싱한다() {
        // Given: 외부 API 응답 스텁
        stubFor(post(urlEqualTo("/api/v1/payments"))
            .withHeader("Content-Type", containing("application/json"))
            .withRequestBody(matchingJsonPath("$.amount"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {
                        "transactionId": "txn_abc123",
                        "status": "APPROVED",
                        "approvedAmount": 50000
                    }
                    """)));

        // When
        PaymentResponse response = client.charge(
            new PaymentRequest("order-1", 50000, "KRW"));

        // Then
        assertThat(response.transactionId()).isEqualTo("txn_abc123");
        assertThat(response.status()).isEqualTo("APPROVED");

        // 요청 검증
        verify(postRequestedFor(urlEqualTo("/api/v1/payments"))
            .withRequestBody(matchingJsonPath("$.amount", equalTo("50000"))));
    }
}

장애 시나리오 테스트

WireMock의 핵심 강점은 장애 시나리오를 손쉽게 재현할 수 있다는 것입니다.

@Test
void 타임아웃_발생시_폴백_응답을_반환한다() {
    // 5초 지연 → 클라이언트 타임아웃 유발
    stubFor(post(urlEqualTo("/api/v1/payments"))
        .willReturn(aResponse()
            .withStatus(200)
            .withFixedDelay(5000)  // 5초 지연
            .withBody("{"status":"APPROVED"}")));

    assertThatThrownBy(() -> client.charge(request))
        .isInstanceOf(PaymentTimeoutException.class);
}

@Test
void 서버_에러시_재시도_후_성공한다() {
    // 첫 번째 요청: 500 에러
    stubFor(post(urlEqualTo("/api/v1/payments"))
        .inScenario("retry-scenario")
        .whenScenarioStateIs(STARTED)
        .willReturn(aResponse().withStatus(500))
        .willSetStateTo("RETRY_1"));

    // 두 번째 요청: 성공
    stubFor(post(urlEqualTo("/api/v1/payments"))
        .inScenario("retry-scenario")
        .whenScenarioStateIs("RETRY_1")
        .willReturn(aResponse()
            .withStatus(200)
            .withBody("{"transactionId":"txn_retry","status":"APPROVED"}")));

    PaymentResponse response = client.charge(request);

    assertThat(response.status()).isEqualTo("APPROVED");
    verify(2, postRequestedFor(urlEqualTo("/api/v1/payments")));
}

@Test
void 네트워크_연결_끊김_시나리오() {
    stubFor(post(urlEqualTo("/api/v1/payments"))
        .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));

    assertThatThrownBy(() -> client.charge(request))
        .isInstanceOf(PaymentConnectionException.class);
}

@Test
void 잘못된_응답_본문_처리() {
    stubFor(post(urlEqualTo("/api/v1/payments"))
        .willReturn(aResponse()
            .withStatus(200)
            .withBody("이것은 JSON이 아닙니다")));

    assertThatThrownBy(() -> client.charge(request))
        .isInstanceOf(PaymentParseException.class);
}

응답 템플릿: 동적 응답

WireMock의 Response Templating을 사용하면 요청 내용에 따라 동적 응답을 생성할 수 있습니다.

@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
    .options(wireMockConfig()
        .dynamicPort()
        .extensions(new ResponseTemplateTransformer(false)))
    .build();

@Test
void 요청_데이터를_응답에_반영한다() {
    wireMock.stubFor(post(urlEqualTo("/api/v1/payments"))
        .willReturn(aResponse()
            .withStatus(200)
            .withHeader("Content-Type", "application/json")
            .withBody("""
                {
                    "transactionId": "txn_{{randomValue length=8 type='ALPHANUMERIC'}}",
                    "orderId": "{{jsonPath request.body '$.orderId'}}",
                    "amount": {{jsonPath request.body '$.amount'}},
                    "status": "APPROVED",
                    "processedAt": "{{now format='yyyy-MM-dd HH:mm:ss'}}"
                }
                """)
            .withTransformers("response-template")));

    PaymentResponse response = client.charge(
        new PaymentRequest("order-42", 75000, "KRW"));

    assertThat(response.orderId()).isEqualTo("order-42");
    assertThat(response.transactionId()).startsWith("txn_");
}

JSON 파일 기반 스텁

복잡한 응답은 JSON 파일로 관리하면 가독성이 좋습니다.

// src/test/resources/wiremock/mappings/payment-success.json
{
    "request": {
        "method": "POST",
        "urlPattern": "/api/v1/payments",
        "bodyPatterns": [
            { "matchesJsonPath": "$.amount" }
        ]
    },
    "response": {
        "status": 200,
        "headers": {
            "Content-Type": "application/json"
        },
        "bodyFileName": "payment-approved.json"
    }
}

// src/test/resources/wiremock/__files/payment-approved.json
{
    "transactionId": "txn_test_001",
    "status": "APPROVED",
    "approvedAmount": 50000,
    "currency": "KRW"
}
// 파일 기반 스텁 자동 로드
@SpringBootTest
@AutoConfigureWireMock(port = 0, stubs = "classpath:/wiremock")
class PaymentClientFileStubTest {

    @Autowired
    private PaymentGatewayClient client;

    @Test
    void 파일_스텁으로_결제_성공_테스트() {
        PaymentResponse response = client.charge(request);
        assertThat(response.status()).isEqualTo("APPROVED");
    }
}

요청 매칭 심화

WireMock은 다양한 매칭 전략을 제공합니다.

// URL 패턴 매칭
stubFor(get(urlPathMatching("/api/v1/orders/[a-z0-9-]+"))
    .willReturn(okJson("{}")));

// 헤더 매칭
stubFor(get(anyUrl())
    .withHeader("Authorization", matching("Bearer .*"))
    .withHeader("X-Request-Id", matching("[a-f0-9-]{36}"))
    .willReturn(ok()));

// 쿼리 파라미터
stubFor(get(urlPathEqualTo("/api/v1/products"))
    .withQueryParam("category", equalTo("electronics"))
    .withQueryParam("page", matching("[0-9]+"))
    .willReturn(okJson("{"products":[]}")));

// JSON 본문 깊은 매칭
stubFor(post(urlEqualTo("/api/v1/orders"))
    .withRequestBody(matchingJsonPath("$.items[?(@.quantity > 0)]"))
    .withRequestBody(matchingJsonPath("$.customer.email",
        matching(".*@.*\..*")))
    .willReturn(aResponse().withStatus(201)));

// 우선순위: 구체적 매칭이 먼저
stubFor(get(urlEqualTo("/api/v1/orders/special"))
    .atPriority(1)
    .willReturn(okJson("{"type":"special"}")));

stubFor(get(urlPathMatching("/api/v1/orders/.*"))
    .atPriority(5)
    .willReturn(okJson("{"type":"general"}")));

Spring RestClient/WebClient 테스트

실제 프로젝트에서 RestClient나 WebClient를 사용하는 경우의 테스트 패턴입니다.

// 테스트 대상 클라이언트
@Component
public class PaymentGatewayClient {

    private final RestClient restClient;

    public PaymentGatewayClient(
            RestClient.Builder builder,
            @Value("${payment.api.base-url}") String baseUrl) {
        this.restClient = builder
            .baseUrl(baseUrl)
            .defaultHeader("X-Api-Key", "test-key")
            .build();
    }

    public PaymentResponse charge(PaymentRequest request) {
        return restClient.post()
            .uri("/api/v1/payments")
            .contentType(MediaType.APPLICATION_JSON)
            .body(request)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
                throw new PaymentRejectedException(res.getStatusCode().value());
            })
            .onStatus(HttpStatusCode::is5xxServerError, (req, res) -> {
                throw new PaymentServerException("서버 오류");
            })
            .body(PaymentResponse.class);
    }
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 0)
class PaymentGatewayClientIntegrationTest {

    @Autowired private PaymentGatewayClient client;

    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("payment.api.base-url",
            () -> "http://localhost:${wiremock.server.port}");
    }

    @Test
    void 결제_거절시_적절한_예외를_던진다() {
        stubFor(post(urlEqualTo("/api/v1/payments"))
            .willReturn(aResponse()
                .withStatus(422)
                .withBody("{"error":"카드 한도 초과"}")));

        assertThatThrownBy(() -> client.charge(request))
            .isInstanceOf(PaymentRejectedException.class);
    }
}

녹화와 재생

실제 API 응답을 녹화하여 스텁으로 재사용할 수 있습니다.

// 녹화 모드로 WireMock 실행
WireMockServer wireMock = new WireMockServer(
    wireMockConfig()
        .port(8089)
        .recordMappingsFor(new SnapshotRecordResult()));

wireMock.start();

// 실제 API로 프록시하면서 녹화
wireMock.startRecording(
    recordSpec()
        .forTarget("https://api.payment-provider.com")
        .captureHeader("Content-Type")
        .makeStubsPersistent(true)
        .transformers("modify-response-header"));

// 테스트 실행 후 녹화 중지
wireMock.stopRecording();
// → src/test/resources/wiremock/ 에 스텁 파일 생성됨

실전 팁

  • 동적 포트: @AutoConfigureWireMock(port = 0)으로 랜덤 포트를 사용하면 테스트 병렬 실행 시 포트 충돌을 방지합니다
  • 시나리오: inScenario로 상태 전이를 모델링하면 Resilience4j 재시도 로직을 정확히 테스트할 수 있습니다
  • verify 필수: 응답만 확인하지 말고, verify()로 요청이 올바르게 전송되었는지도 검증합니다
  • 스텁 파일 관리: 복잡한 프로젝트에서는 wiremock/mappings/wiremock/__files/ 디렉토리로 스텁을 체계적으로 관리합니다
  • Testcontainers와 역할 분담: DB/메시지 큐는 Testcontainers, 외부 HTTP API는 WireMock으로 분담합니다

마무리

WireMock은 외부 API 의존성을 가진 서비스의 테스트를 안정적이고 재현 가능하게 만드는 필수 도구입니다. 성공/실패/타임아웃/네트워크 장애 등 모든 시나리오를 코드로 재현할 수 있어, 프로덕션에서 발생할 수 있는 edge case를 사전에 검증할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux