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를 사전에 검증할 수 있습니다.