WireMock — Testing External REST API Integrations
Most Spring Boot applications call external APIs — payment gateways, shipping providers, notification services, CRM systems. Testing these integrations is hard because external APIs have rate limits, authentication requirements, and a habit of being unavailable at exactly the wrong moment. WireMock runs a real HTTP server in a Docker container, accepts your requests, and returns whatever responses you configure. This article covers WireMock as a Testcontainer for testing Spring Boot REST client integrations.
What You’ll Learn
WireMockContainersetup and configuration- Stubbing GET, POST, and error responses
- Request matching — URL, headers, body patterns
- Verifying outbound requests
- Stateful stubs (WireMock scenarios)
- Testing retry and circuit breaker behavior
- Testing timeout and slow response scenarios
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.wiremock.integrations</groupId>
<artifactId>wiremock-testcontainers-java</artifactId>
<version>1.0-alpha-14</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
The Domain: External Payment Service
The order service calls an external payment gateway to process payments:
public record PaymentRequest(
String orderId,
BigDecimal amount,
String currency,
String customerId
) {}
public record PaymentResponse(
String paymentId,
String status,
String message
) {}
@Service
public class PaymentGatewayClient {
private final RestClient restClient;
private final String baseUrl;
public PaymentGatewayClient(RestClient.Builder builder,
@Value("${payment.gateway.url}") String baseUrl) {
this.restClient = builder.baseUrl(baseUrl).build();
this.baseUrl = baseUrl;
}
public PaymentResponse processPayment(PaymentRequest request) {
return restClient.post()
.uri("/api/v1/payments")
.header("Content-Type", "application/json")
.header("X-API-Key", "secret-api-key")
.body(request)
.retrieve()
.body(PaymentResponse.class);
}
public PaymentResponse getPaymentStatus(String paymentId) {
return restClient.get()
.uri("/api/v1/payments/{id}", paymentId)
.retrieve()
.body(PaymentResponse.class);
}
}
WireMockContainer Setup
@SpringBootTest
@Testcontainers
class PaymentGatewayClientTest {
@Container
static WireMockContainer wiremock =
new WireMockContainer("wiremock/wiremock:3.3.1")
.withMappingFromURL("payment-stubs",
PaymentGatewayClientTest.class.getResource("/wiremock/payment-stubs.json"));
@DynamicPropertySource
static void configurePaymentGateway(DynamicPropertyRegistry registry) {
registry.add("payment.gateway.url", wiremock::getBaseUrl);
}
@Autowired
private PaymentGatewayClient paymentClient;
}
Alternatively, configure stubs programmatically using the WireMock Java client:
@Container
static WireMockContainer wiremock = new WireMockContainer("wiremock/wiremock:3.3.1");
private WireMock wireMockClient;
@BeforeEach
void setupWireMock() {
wireMockClient = new WireMock(wiremock.getHost(), wiremock.getPort());
wireMockClient.resetMappings();
}
Stubbing Responses
Successful Payment
@Test
void shouldProcessPaymentSuccessfully() {
wireMockClient.register(
post(urlEqualTo("/api/v1/payments"))
.withHeader("Content-Type", equalTo("application/json"))
.withHeader("X-API-Key", equalTo("secret-api-key"))
.withRequestBody(matchingJsonPath("$.orderId", equalTo("order-1")))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"paymentId": "pay-abc123",
"status": "APPROVED",
"message": "Payment processed successfully"
}
""")
)
);
PaymentRequest request = new PaymentRequest(
"order-1", BigDecimal.valueOf(99.99), "USD", "customer-1"
);
PaymentResponse response = paymentClient.processPayment(request);
assertThat(response.paymentId()).isEqualTo("pay-abc123");
assertThat(response.status()).isEqualTo("APPROVED");
}
Payment Declined
@Test
void shouldHandlePaymentDeclined() {
wireMockClient.register(
post(urlEqualTo("/api/v1/payments"))
.willReturn(
aResponse()
.withStatus(402)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"paymentId": null,
"status": "DECLINED",
"message": "Insufficient funds"
}
""")
)
);
PaymentRequest request = new PaymentRequest(
"order-2", BigDecimal.valueOf(9999.99), "USD", "customer-1"
);
assertThatThrownBy(() -> paymentClient.processPayment(request))
.isInstanceOf(PaymentDeclinedException.class)
.hasMessageContaining("Insufficient funds");
}
Server Error with Retry
Test that your HTTP client retries on 5xx errors:
@Test
void shouldRetryOnServerError() {
// First call returns 503, second returns 200
wireMockClient.register(
post(urlEqualTo("/api/v1/payments"))
.inScenario("retry-scenario")
.whenScenarioStateIs(Scenario.STARTED)
.willReturn(aResponse().withStatus(503))
.willSetStateTo("first-retry")
);
wireMockClient.register(
post(urlEqualTo("/api/v1/payments"))
.inScenario("retry-scenario")
.whenScenarioStateIs("first-retry")
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"paymentId": "pay-retry", "status": "APPROVED", "message": "OK"}
""")
)
);
PaymentRequest request = new PaymentRequest(
"order-3", BigDecimal.valueOf(50.00), "USD", "customer-1"
);
// With retry configured on RestClient (e.g., via Spring Retry or Resilience4j)
PaymentResponse response = paymentClient.processPayment(request);
assertThat(response.status()).isEqualTo("APPROVED");
}
Verifying Outbound Requests
WireMock lets you verify that your client sent the expected requests:
@Test
void shouldIncludeApiKeyInPaymentRequest() {
wireMockClient.register(
post(urlEqualTo("/api/v1/payments"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"paymentId\":\"p1\",\"status\":\"APPROVED\",\"message\":\"OK\"}")
)
);
PaymentRequest request = new PaymentRequest(
"order-1", BigDecimal.valueOf(99.99), "USD", "customer-1"
);
paymentClient.processPayment(request);
// Verify the request was made with correct headers
wireMockClient.verifyThat(
postRequestedFor(urlEqualTo("/api/v1/payments"))
.withHeader("X-API-Key", equalTo("secret-api-key"))
.withHeader("Content-Type", containing("application/json"))
.withRequestBody(matchingJsonPath("$.currency", equalTo("USD")))
);
}
@Test
void shouldMakeExactlyOnePaymentRequest() {
wireMockClient.register(
post(urlEqualTo("/api/v1/payments"))
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"paymentId\":\"p1\",\"status\":\"APPROVED\",\"message\":\"OK\"}")
)
);
paymentClient.processPayment(
new PaymentRequest("order-1", BigDecimal.valueOf(50.00), "USD", "customer-1")
);
wireMockClient.verifyThat(1, postRequestedFor(urlEqualTo("/api/v1/payments")));
}
Testing Timeout Behavior
@Test
void shouldTimeOutWhenPaymentGatewayIsSlowToRespond() {
wireMockClient.register(
post(urlEqualTo("/api/v1/payments"))
.willReturn(
aResponse()
.withStatus(200)
.withFixedDelay(5000) // 5 second delay
.withHeader("Content-Type", "application/json")
.withBody("{\"paymentId\":\"p1\",\"status\":\"APPROVED\",\"message\":\"OK\"}")
)
);
PaymentRequest request = new PaymentRequest(
"order-1", BigDecimal.valueOf(99.99), "USD", "customer-1"
);
// Assumes RestClient is configured with a 2-second read timeout
assertThatThrownBy(() -> paymentClient.processPayment(request))
.isInstanceOf(PaymentGatewayTimeoutException.class);
}
Using WireMock JSON Stub Files
Store stubs as JSON files in src/test/resources/wiremock/__files/ and src/test/resources/wiremock/mappings/:
// src/test/resources/wiremock/mappings/payment-success.json
{
"request": {
"method": "POST",
"url": "/api/v1/payments",
"headers": {
"X-API-Key": { "equalTo": "secret-api-key" }
}
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"bodyFileName": "payment-success-response.json"
}
}
// src/test/resources/wiremock/__files/payment-success-response.json
{
"paymentId": "pay-file-stub",
"status": "APPROVED",
"message": "Payment processed"
}
Load stub files when creating the container:
static WireMockContainer wiremock = new WireMockContainer("wiremock/wiremock:3.3.1")
.withFileSystemBind("src/test/resources/wiremock", "/home/wiremock");
Common Pitfalls
Not resetting mappings between tests. Stubs accumulate between tests unless reset. Call wireMockClient.resetMappings() in @BeforeEach.
Overly strict request matching. If your stub requires an exact match on request body and your client sends an extra field, the stub will not match and your test will fail with an unexpected response. Use matchingJsonPath for specific fields rather than exact body matching.
Forgetting Content-Type headers on stub responses. Spring’s HTTP clients inspect the Content-Type header to deserialize the response. Without it, JSON deserialization fails. Always include withHeader("Content-Type", "application/json").
Summary
WireMockContainer provides a real HTTP server for testing REST client integrations. Stubbing, request verification, stateful scenarios, and delay injection give you complete control over the external API behavior your code encounters. Tests that previously required a real payment gateway or shipping API can run reliably in any environment.
The next article covers LocalStack — testing AWS service integrations (S3, SQS, SNS) locally.