WireMock Tutorial: Mocking HTTP Services in Integration Tests

WireMock Tutorial: Mocking HTTP Services in Integration Tests

WireMock is an HTTP mock server that lets you stub external API responses and verify that your code makes the expected calls. It's the right tool when your integration tests need a real HTTP server to call (not a mock object) without depending on an actual external service. This tutorial covers setup, request stubbing, response templating, verification, and common patterns.

Key Takeaways

WireMock stubs at the HTTP level, not the code level. Unlike mocking libraries that replace Java objects, WireMock starts a real HTTP server. Your code calls real URLs; WireMock answers them. This tests serialization, URL construction, and HTTP error handling.

Use scenario states for multi-step workflows. WireMock's scenario API lets you simulate stateful behavior: first call returns 202 Accepted, second call returns 200 OK with results.

Verify requests, not just responses. Stubbing a response doesn't verify your code made the right call. Use verify() to assert that the expected request was made with the correct URL, headers, and body.

Record and replay real API responses. WireMock's record mode captures real API responses to use as stubs. This gives you realistic test data without manual fixture creation.

Test error scenarios explicitly. Network errors, 503s, slow responses — these are the cases that cause production incidents. WireMock makes them easy to inject into tests.

Why HTTP Mocking at the Server Level

When your code calls an external HTTP API, you have several options for testing:

Option 1: Call the real API. Tests depend on network availability, API rate limits, test account state, and costs. CI tests fail intermittently. Running tests costs money. Not viable for large test suites.

Option 2: Mock the HTTP client object. Replace HttpClient or requests.Session with a mock. Fast and reliable, but doesn't test URL construction, header formatting, or JSON serialization. The mock might accept a malformed request that the real API would reject.

Option 3: WireMock — a real HTTP server. Your code calls real HTTP. WireMock answers. Tests are fast (local network), reliable (no external dependencies), and test the full HTTP stack including serialization and URL construction.

WireMock is option 3, and it's the right choice when you need confidence that your HTTP client code actually works.

Setup

Java / JUnit 5

<dependency>
    <groupId>org.wiremock</groupId>
    <artifactId>wiremock-standalone</artifactId>
    <version>3.3.1</version>
    <scope>test</scope>
</dependency>
@ExtendWith(WireMockExtension.class)
class PaymentServiceTest {

    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
        .options(wireMockConfig().dynamicPort())
        .build();

    private PaymentService paymentService;

    @BeforeEach
    void setUp() {
        // Configure the service to call WireMock's URL
        String baseUrl = wireMock.baseUrl();
        paymentService = new PaymentService(baseUrl);
    }
}

dynamicPort() assigns a random available port, avoiding conflicts when multiple test classes run in parallel.

Python (requests-mock / responses)

For Python, responses or requests-mock provide similar functionality:

pip install responses pytest-responses
import responses
import pytest
from my_service import PaymentService

@responses.activate
def test_processes_payment_successfully():
    responses.add(
        responses.POST,
        "https://api.payment-gateway.com/charges",
        json={"id": "ch_123", "status": "succeeded"},
        status=200
    )
    
    service = PaymentService()
    result = service.charge("tok_visa", 9999)
    
    assert result.charge_id == "ch_123"
    assert result.status == "succeeded"

Node.js (nock)

const nock = require('nock');
const { PaymentService } = require('./payment-service');

test('processes payment successfully', async () => {
    nock('https://api.payment-gateway.com')
        .post('/charges', { amount: 9999, token: 'tok_visa' })
        .reply(200, { id: 'ch_123', status: 'succeeded' });

    const service = new PaymentService();
    const result = await service.charge('tok_visa', 9999);

    expect(result.chargeId).toBe('ch_123');
    expect(result.status).toBe('succeeded');
});

Core Stubbing Patterns

Stub by URL and Method

wireMock.stubFor(get(urlEqualTo("/users/123"))
    .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("""
            {
                "id": 123,
                "name": "Alice Johnson",
                "email": "alice@example.com",
                "tier": "pro"
            }
            """)));

Stub with URL Pattern Matching

// Match any user ID
wireMock.stubFor(get(urlMatching("/users/\\d+"))
    .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBodyFile("responses/user.json")));  // Load from __files/ directory

Using withBodyFile() keeps test code clean and lets you maintain response fixtures in separate JSON files.

Stub with Request Body Matching

wireMock.stubFor(post(urlEqualTo("/charges"))
    .withHeader("Authorization", equalTo("Bearer sk_test_key"))
    .withRequestBody(matchingJsonPath("$.amount", equalTo("9999")))
    .withRequestBody(matchingJsonPath("$.currency", equalTo("USD")))
    .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("""
            {"id": "ch_123", "status": "succeeded"}
            """)));

matchingJsonPath() with JSON path expressions lets you match specific fields in the request body without matching the entire JSON structure.

Stub with Header Matching

wireMock.stubFor(get(urlEqualTo("/orders"))
    .withHeader("X-API-Key", equalTo("test-api-key"))
    .withHeader("Accept", containing("application/json"))
    .willReturn(okJson("""
        {"orders": [], "total": 0}
        """)));

Testing Error Scenarios

Error scenarios are where WireMock provides the most value — these are exactly the cases that are hard to reproduce with real external services.

4xx Errors

@Test
void returnsNotFoundWhenUserDoesNotExist() {
    wireMock.stubFor(get(urlEqualTo("/users/999"))
        .willReturn(aResponse()
            .withStatus(404)
            .withHeader("Content-Type", "application/json")
            .withBody("""
                {"error": "User not found", "code": "USER_NOT_FOUND"}
                """)));

    assertThatThrownBy(() -> userClient.getUser(999))
        .isInstanceOf(UserNotFoundException.class)
        .hasMessageContaining("999");
}

5xx Errors and Retries

@Test
void retriesOnServerError() {
    // First two calls fail with 503
    wireMock.stubFor(get(urlEqualTo("/inventory/product-1"))
        .inScenario("RetryScenario")
        .whenScenarioStateIs(STARTED)
        .willSetStateTo("First retry")
        .willReturn(aResponse().withStatus(503)));

    wireMock.stubFor(get(urlEqualTo("/inventory/product-1"))
        .inScenario("RetryScenario")
        .whenScenarioStateIs("First retry")
        .willSetStateTo("Second retry")
        .willReturn(aResponse().withStatus(503)));

    // Third call succeeds
    wireMock.stubFor(get(urlEqualTo("/inventory/product-1"))
        .inScenario("RetryScenario")
        .whenScenarioStateIs("Second retry")
        .willReturn(okJson("""{"productId": "product-1", "stock": 10}""")));

    InventoryItem item = inventoryClient.getStock("product-1");

    assertThat(item.getStock()).isEqualTo(10);
    
    // Verify it made exactly 3 requests (2 retries + 1 success)
    wireMock.verify(3, getRequestedFor(urlEqualTo("/inventory/product-1")));
}

Network Delays and Timeouts

@Test
void throwsTimeoutExceptionWhenResponseIsTooSlow() {
    wireMock.stubFor(get(urlEqualTo("/reports/generate"))
        .willReturn(aResponse()
            .withStatus(200)
            .withFixedDelay(5000)  // 5 second delay
            .withBody("""{"status": "done"}""")));

    // Client configured with 2 second timeout
    assertThatThrownBy(() -> reportClient.generateReport())
        .isInstanceOf(RequestTimeoutException.class);
}

@Test
void handlesConnectionRefused() {
    wireMock.stubFor(get(urlEqualTo("/health"))
        .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));

    assertThatThrownBy(() -> healthClient.check())
        .isInstanceOf(ServiceUnavailableException.class);
}

Fault options: CONNECTION_RESET_BY_PEER, EMPTY_RESPONSE, MALFORMED_RESPONSE_CHUNK, RANDOM_DATA_THEN_CLOSE.

Request Verification

Stubbing tests that your code handles responses correctly. Verification tests that your code makes the right request in the first place.

@Test
void includesAuthorizationHeaderInRequest() {
    wireMock.stubFor(post(urlEqualTo("/charges"))
        .willReturn(okJson("""{"id": "ch_123", "status": "succeeded"}""")));

    paymentService.charge("card-token", 9999);

    // Verify the request was made with the correct Authorization header
    wireMock.verify(postRequestedFor(urlEqualTo("/charges"))
        .withHeader("Authorization", equalTo("Bearer sk_test_key"))
        .withHeader("Content-Type", equalTo("application/json"))
        .withRequestBody(matchingJsonPath("$.amount", equalTo("9999")))
        .withRequestBody(matchingJsonPath("$.currency", equalTo("USD"))));
}

@Test
void doesNotMakeRequestWhenAmountIsZero() {
    // No stub needed — we're verifying no call is made
    
    paymentService.charge("card-token", 0);
    
    // Verify no requests were made
    wireMock.verify(0, postRequestedFor(urlEqualTo("/charges")));
}

Response Templating

WireMock's response templating uses Handlebars to generate dynamic responses based on request data:

wireMock.stubFor(get(urlMatching("/users/(?<userId>[0-9]+)"))
    .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("""
            {
                "id": {{request.pathSegments.[1]}},
                "name": "Test User {{request.pathSegments.[1]}}",
                "requestedAt": "{{now}}"
            }
            """)
        .withTransformers("response-template")));

With templating enabled, a GET to /users/456 returns {"id": 456, "name": "Test User 456", ...}. This avoids maintaining separate stubs for every possible input.

Record and Replay

WireMock can record real API interactions and save them as stubs:

// Start WireMock in recording mode
wireMock.startRecording(recordSpec()
    .forTarget("https://api.real-service.com")
    .onlyRequestsMatching(getRequestedFor(urlPathMatching("/v2/.*")))
    .makeStubsPersistent(true)
    .ignoreRepeatRequests());

// Run your application against WireMock — it proxies to the real API and records
// Run the scenarios you want to capture

// Stop recording — stubs are saved to the mappings directory
wireMock.stopRecording();

The saved stub files can be committed to the repository. Future test runs use the recorded responses without hitting the real API.

This is particularly useful for APIs with complex response structures that are error-prone to write manually.

Using WireMock in CI

For CI environments, WireMock runs as an embedded server (no external process needed) with dynamicPort():

@ExtendWith(WireMockExtension.class)
class ServiceIntegrationTest {

    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
        .options(wireMockConfig()
            .dynamicPort()
            .dynamicHttpsPort()   // Optional HTTPS support
            .usingFilesUnderDirectory("src/test/resources/wiremock"))
        .build();
}

The usingFilesUnderDirectory() option loads stub mappings and response body files from a directory. This lets you organize stubs in version-controlled JSON files rather than Java code.

Stub mapping file (src/test/resources/wiremock/mappings/get-user.json):

{
    "request": {
        "method": "GET",
        "urlPattern": "/users/[0-9]+"
    },
    "response": {
        "status": 200,
        "headers": {
            "Content-Type": "application/json"
        },
        "bodyFileName": "user-response.json"
    }
}

Response body file (src/test/resources/wiremock/__files/user-response.json):

{
    "id": 123,
    "name": "Test User",
    "email": "test@example.com"
}

Common Mistakes

Stub not matched → 404 from WireMock. When WireMock returns 404 unexpectedly, it means no stub matched the request. Enable request logging (wireMockConfig().notifier(new ConsoleNotifier(true))) to see what WireMock received vs what you stubbed.

Missing withTransformers("response-template"). Response templating requires explicitly enabling the transformer on each stub. Forgetting this causes templates to be returned literally.

Not resetting between tests. Add @AfterEach void reset() { wireMock.resetAll(); } or use @WireMockTest which resets automatically. Stubs from one test bleeding into another is a common source of flakiness.

Verifying too much. Verifying every request parameter makes tests brittle. Verify what matters (the key security header, the critical request field) and ignore the rest.

Summary

WireMock provides HTTP-level test isolation for any service that makes outbound HTTP calls. It's the right tool when you need confidence that your HTTP client code correctly handles the full range of responses — success, client errors, server errors, timeouts, and network failures.

The test pattern is always: stub the expected responses, exercise your code, verify the expected requests were made. Add scenario states for multi-step workflows and use response templating for dynamic fixtures.

Mock what you can't control (external APIs). Test what you own (how your code handles the responses).

Read more