WireMock & External APIs
Code that calls a third-party HTTP API is hard to test: the real service is slow, rate-limited, costs money, or simply isn’t reachable from CI. WireMock solves this by running a real HTTP server that returns the responses you program — so your WebClient or RestClient makes genuine network calls, but to a server you control. That makes it ideal for asserting how your code behaves on success, errors, timeouts, and malformed responses.
Unlike mocking the HTTP client with Mockito, WireMock exercises the real serialization, connection, and error-handling paths — closer to production while still deterministic.
Adding the dependency
WireMock publishes a JUnit 5 module. For Spring Boot 3 use the Jakarta-compatible standalone artifact:
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.9.1</version>
<scope>test</scope>
</dependency>
The class under test calls an external exchange-rate API:
@Service
public class RateClient {
private final RestClient restClient;
public RateClient(RestClient.Builder builder,
@Value("${rates.base-url}") String baseUrl) {
this.restClient = builder.baseUrl(baseUrl).build();
}
public BigDecimal usdToEur() {
Rate rate = restClient.get()
.uri("/v1/usd/eur")
.retrieve()
.body(Rate.class);
return rate.value();
}
public record Rate(BigDecimal value) {}
}
Stubbing responses with @WireMockTest
@WireMockTest starts a WireMock server on a random port for the test and injects details via WireMockRuntimeInfo. Point the application’s base URL at that port:
@SpringBootTest
@WireMockTest(httpPort = 0)
class RateClientTest {
@Autowired RateClient rateClient;
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("rates.base-url",
() -> "http://localhost:" + RateClientTest.port);
}
static int port;
@BeforeAll
static void capturePort(WireMockRuntimeInfo info) {
port = info.getHttpPort();
}
@Test
void returnsTheRate() {
stubFor(get("/v1/usd/eur")
.willReturn(okJson("""
{ "value": 0.92 }
""")));
assertThat(rateClient.usdToEur())
.isEqualByComparingTo("0.92");
}
}
stubFor(...), get(...), and okJson(...) are static imports from com.github.tomakehurst.wiremock.client.WireMock.
Note: Because the random port is needed before the Spring context starts, the example above captures it in
@BeforeAll. The cleaner alternative is theWireMockExtensionbelow, where you control the lifecycle explicitly.
Using WireMockExtension for full control
WireMockExtension is a @RegisterExtension field — useful when you want a fixed config, options like response templating, or to read the port early for @DynamicPropertySource:
@SpringBootTest
class RateClientExtensionTest {
@RegisterExtension
static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build();
@Autowired RateClient rateClient;
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("rates.base-url", wm::baseUrl);
}
@Test
void parsesSuccessResponse() {
wm.stubFor(get(urlPathEqualTo("/v1/usd/eur"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"value\": 0.91}")));
assertThat(rateClient.usdToEur()).isEqualByComparingTo("0.91");
}
}
Tip: A
staticextension instance is shared across all tests in the class and resets stubs between methods. Use a non-static field with@RegisterExtensionif you want a fresh server per test method.
Verifying outgoing requests
Beyond returning data, WireMock records every request, so you can assert your client sent the right path, headers, query params, and body:
@Test
void sendsApiKeyHeader() {
wm.stubFor(get(urlPathEqualTo("/v1/usd/eur"))
.willReturn(okJson("{\"value\": 0.92}")));
rateClient.usdToEur();
wm.verify(getRequestedFor(urlPathEqualTo("/v1/usd/eur"))
.withHeader("Accept", containing("application/json")));
}
Matching requests precisely
Stubs can match on more than the path — query params, headers, and JSON body:
| Matcher | Purpose |
|---|---|
urlPathEqualTo("/x") | Exact path, ignoring query string |
withQueryParam("base", equalTo("usd")) | Query parameter value |
withHeader("Authorization", containing("Bearer")) | Header substring |
withRequestBody(equalToJson("{...}")) | Structural JSON body match |
priority(n) | Disambiguate overlapping stubs |
Simulating failures and latency
This is where WireMock pays off for resilience testing — drive your retry, timeout, and circuit-breaker logic by injecting faults:
// Server error → assert your error handling / retry kicks in
wm.stubFor(get(urlPathEqualTo("/v1/usd/eur"))
.willReturn(aResponse().withStatus(503)));
// Slow response → assert your read timeout fires
wm.stubFor(get(urlPathEqualTo("/v1/usd/eur"))
.willReturn(okJson("{\"value\": 0.92}")
.withFixedDelay(5000)));
// Protocol-level fault → assert the client surfaces a connection error
wm.stubFor(get(urlPathEqualTo("/v1/usd/eur"))
.willReturn(aResponse()
.withFault(Fault.CONNECTION_RESET_BY_PEER)));
A timeout test then asserts the right exception propagates:
@Test
void readTimeoutSurfacesException() {
wm.stubFor(get(urlPathEqualTo("/v1/usd/eur"))
.willReturn(okJson("{\"value\": 0.92}").withFixedDelay(5000)));
assertThatThrownBy(() -> rateClient.usdToEur())
.isInstanceOf(ResourceAccessException.class);
}
Warning: Make sure the client’s read timeout is actually configured (e.g. via
ClientHttpRequestFactorySettingson theRestClient.Builder). Without a timeout,withFixedDelaysimply makes the test slow instead of triggering the failure path you intended to verify.
WireMock as a container
For integration tests that already use Testcontainers, the official WireMock module starts the server in Docker and keeps test infrastructure uniform:
@Container
static WireMockContainer wiremock =
new WireMockContainer("wiremock/wiremock:3.9.1");
Point rates.base-url at wiremock.getBaseUrl() via @DynamicPropertySource. The container approach is heavier than the in-process extension, so prefer the extension for unit-level client tests and reserve containers for full-stack scenarios.