WireMock for HTTP Service Stubbing

9 min read

When your service calls another service via HTTP, WireMock lets you run a real HTTP server in your test that responds exactly as you configure it — no real service needed. The HTTP stack, connection handling, headers, and status codes all behave exactly as they would in production. This is the critical difference from mocking: a mock intercepts the call at the Java method level; WireMock intercepts it at the network level. Timeouts, retries, and fault-tolerance code that never fires against a mock will fire against WireMock because they're responding to real socket behaviour.

Adding WireMock to your project

<dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock-standalone</artifactId>
    <version>3.6.0</version>
    <scope>test</scope>
</dependency>

For Gradle: testImplementation 'org.wiremock:wiremock-standalone:3.6.0'

Basic stubbing setup

WireMock 3.x integrates cleanly with JUnit 5 through WireMockExtension. The dynamicPort() option allocates a random available port, which is essential for parallel test runs — multiple tests won't clash over the same port number.

@ExtendWith(WireMockExtension.class)
class PaymentServiceClientTest {
 
    @RegisterExtension
    static WireMockExtension wm = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())
        .build();
 
    @Test
    void shouldProcessPaymentSuccessfully() {
        wm.stubFor(post(urlEqualTo("/payments"))
            .withHeader("Content-Type", containing("application/json"))
            .withRequestBody(matchingJsonPath("$.amount", equalTo("99.99")))
            .willReturn(aResponse()
                .withStatus(201)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {
                        "paymentId": "pay_abc123",
                        "status": "APPROVED",
                        "processedAt": "2024-01-15T10:30:00Z"
                    }
                """)
            ));
 
        PaymentResult result = paymentClient.charge(new ChargeRequest("card_123", 99.99));
 
        assertThat(result.getStatus()).isEqualTo("APPROVED");
        assertThat(result.getPaymentId()).isEqualTo("pay_abc123");
    }
}

The stub here is precise: it only matches POST /payments requests that include a JSON body with amount equal to "99.99". Requests that don't match return a 404 by default — which is itself a useful safety net that catches bugs where your service is calling the wrong endpoint or sending the wrong body shape.

Simulating failure scenarios — WireMock's killer feature

This is where WireMock earns its place in every component test suite. You can inject specific network conditions that are impossible to reproduce with mocks and impractical to produce against a real dependency.

// Scenario 1: Server error — test your retry logic
wm.stubFor(post("/payments")
    .willReturn(serverError()));  // Returns 500
 
// Scenario 2: Slow response — test your timeout handling
wm.stubFor(post("/payments")
    .willReturn(okJson("{...}").withFixedDelay(5000)));  // 5 second delay
 
// Scenario 3: Connection failure — test circuit breaker
wm.stubFor(post("/payments")
    .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
 
// Scenario 4: 429 Too Many Requests — test rate limiting behaviour
wm.stubFor(post("/payments")
    .willReturn(aResponse().withStatus(429)
        .withHeader("Retry-After", "2")));

Each scenario maps to a different resilience mechanism in your service. The 500 tests whether your retry interceptor fires and stops after the configured number of attempts. The 5-second delay tests whether your RestTemplate or WebClient timeout actually cuts the connection — many teams discover their timeout configuration was never applied correctly until WireMock exposes it. CONNECTION_RESET_BY_PEER is the most brutal: it simulates a mid-flight connection drop and validates that your circuit breaker opens and stops sending requests downstream. Write one test for each of these scenarios for every external HTTP dependency your service owns.

Stateful stubs — simulating state changes across calls

WireMock's scenario API lets you define stubs that transition through states across sequential calls. This models real service behaviour where two identical requests return different responses depending on what happened between them.

wm.stubFor(get("/inventory/product/100")
    .inScenario("inventory-depletion")
    .whenScenarioStateIs(STARTED)
    .willReturn(okJson("{\"stock\": 3}"))
    .willSetStateTo("low-stock"));
 
wm.stubFor(get("/inventory/product/100")
    .inScenario("inventory-depletion")
    .whenScenarioStateIs("low-stock")
    .willReturn(okJson("{\"stock\": 0}")));

The first call returns stock of 3 and transitions the scenario to "low-stock". The second call sees "low-stock" and returns 0. This lets you test that your service correctly handles the transition — perhaps it should display a "last few remaining" warning at stock 3 and a "sold out" state at 0 — without any real inventory service running.

Verifying outgoing calls

Stubbing is only half the story. WireMock also lets you assert that your service sent the right request — correct headers, correct body structure, correct endpoint. This catches bugs where your service calls a dependency successfully but passes the wrong data.

// Assert YOUR service called the payment provider with correct data
wm.verify(postRequestedFor(urlEqualTo("/payments"))
    .withRequestBody(matchingJsonPath("$.currency", equalTo("GBP")))
    .withHeader("Authorization", matching("Bearer .*")));

This verification step answers a question that an assertion on the response alone cannot: did your service send a properly authenticated request? A stub always returns its configured response regardless of whether the Authorization header was present. The verify call is the only mechanism that confirms your service behaved correctly as a caller, not just as a responder.

WireMock standalone mode

For teams running many services in Docker Compose, WireMock can run as a standalone process — a separate container that all services in the compose stack treat as a shared stub server. You configure it with JSON mapping files placed in a mappings/ directory, and the container serves those stubs to any service that calls it. This is particularly useful when your component tests spin up your service as a container alongside its real database but you still want to stub the payment gateway or email service it calls outbound.

⚠️ Common mistakes

  • Configuring stubs in @BeforeEach and never resetting them. WireMock accumulates stubs across tests if you don't reset. The second test in your class sees stubs left by the first test, creating order-dependent test failures. Call wm.resetAll() in @BeforeEach, or configure the extension with .failOnUnmatchedRequests(true) to catch leftover state immediately.
  • Using anyUrl() for every stub. Broad URL matchers hide bugs. If your service accidentally calls /payment instead of /payments, an anyUrl() stub masks the mistake. Match the exact path and method for every stub — the test suite should reject requests to wrong endpoints, not silently serve them.
  • Forgetting to point your service at WireMock's dynamic port. The stub server listens on a port allocated at runtime. If your service reads its payment gateway URL from a fixed config value, it won't reach WireMock. You must inject wm.getRuntimeInfo().getHttpBaseUrl() into your service configuration before each test — typically through a @DynamicPropertySource in Spring Boot or a system property set in a @BeforeEach.

🎯 Practice task

Test a failure scenario end-to-end — 40 minutes.

  1. Pick one external HTTP call in a service you own or in a sample project. Identify the downstream URL, the HTTP method, and what your service does when it gets a successful response.
  2. Write the happy-path stub first. Configure WireMock to return a valid success response. Write the corresponding test and confirm it passes. This validates your WireMock configuration is correct before you add failure scenarios.
  3. Add a 500 error stub. Replace the success stub with serverError(). Write a test that asserts your service returns the expected error response to its own caller. If the test reveals your service is not handling 500s — just letting the exception propagate unhandled — that is a real bug you just found.
  4. Add a timeout stub. Use withFixedDelay(6000) with a 6-second delay. Configure your service's HTTP client timeout to 3 seconds if it isn't already. Write a test that asserts the timeout fires and the service returns a sensible response within 5 seconds. Measure the actual duration in the test to confirm the timeout is genuinely cutting the connection.
  5. Add a verify assertion. On your happy-path test, add a wm.verify(...) call that checks your service sent the correct Authorization header. Temporarily remove the header from your service code and confirm the verify call catches the regression before the response assertion even runs.

Next lesson: real database testing with Testcontainers — why H2 produces false passes and how to run the actual database engine in your test suite.

// tip to track lessons you complete and pick up where you left off across devices.