DEV Community

Markus
Markus

Posted on • Originally published at the-main-thread.com on

Mock External APIs in Quarkus with WireMock: A Hands-On Guide

Most developers think mocking an external HTTP API is just a testing convenience. They want to avoid calling a real sandbox, so they plug in a fake response and move on. That works for the first demo, but it fails the moment your integration starts depending on behavior, not just shape.

Real carrier APIs do not fail in clean ways. They time out. They drift from their own documentation. They return half-useful payloads. They give you state transitions that happen over time, not one static JSON blob. When your service depends on an API you do not control, the real problem is not “how do I fake one response?” The real problem is “how do I keep development, testing, and failure handling stable when the downstream system is unstable?”

This is where WireMock becomes much more than a stub server. It gives you a real HTTP boundary that your Quarkus application calls through the same REST client it will use in production. That matters because mocks inside your JVM do not test serialization, status handling, retries, request paths, headers, or timeouts. They test a method call. Production breaks at the HTTP boundary.

In this tutorial we build a small shipment tracking feature end to end. Our Quarkus endpoint calls a fictional carrier API through a MicroProfile REST Client. WireMock stands in for that carrier in both dev mode and tests. We start with static JSON stubs, then move to programmatic stubs, stateful scenarios, response templating, fault injection, and request verification.

The result is a setup you can trust when the real carrier is down, when access is delayed, or when you need to test production failure paths on your own machine. That is the part that matters at 2am. Your code still behaves predictably even when the dependency does not.

Prerequisites

You need a current Quarkus setup, basic REST knowledge, and a few minutes to build the example.

  • Java 21 installed

  • Maven or the Quarkus CLI

  • Basic understanding of REST endpoints and JSON

  • Basic understanding of Quarkus REST Client

Project Setup

Create the project or start directly from my Github repository:

quarkus create app dev.mainthread:quarkus-wiremock-demo \
  --extension='rest,rest-jackson,rest-client-jackson' \
  --no-code
cd quarkus-wiremock-demo
Enter fullscreen mode Exit fullscreen mode

We keep the project small on purpose. The only thing we need is a REST endpoint, Jackson for JSON, and a REST client to call the external carrier API.

The extensions do the following:

  • rest - exposes our shipment endpoint

  • rest-jackson - serializes and deserializes JSON

  • rest-client-jackson - creates the HTTP client that talks to the carrier API

Make sure to add the Quarkus-WireMock extensions and rest-assured to your pom.xml:

<dependency>
    <groupId>io.quarkiverse.wiremock</groupId>
    <artifactId>quarkus-wiremock</artifactId>
    <version>1.5.3</version>
    <scope>provided</scope>
</dependency>

    <dependency>
        <groupId>io.quarkiverse.wiremock</groupId>
        <artifactId>quarkus-wiremock-test</artifactId>
        <version>1.5.3</version>
        <scope>test</scope>
    </dependency>

   <dependency>
            <groupId>io.rest-assured</groupId>
            <artifactId>rest-assured</artifactId>
            <scope>test</scope>
        </dependency>
Enter fullscreen mode Exit fullscreen mode

Now configure the application in src/main/resources/application.properties:

quarkus.rest-client.carrier-api.url=http://localhost:${quarkus.wiremock.devservices.port}
quarkus.wiremock.devservices.global-response-templating=true
Enter fullscreen mode Exit fullscreen mode

Here is what each setting does:

  • quarkus.rest-client.carrier-api.url - points the REST client at the WireMock server started by the Dev Service

  • quarkus.wiremock.devservices.global-response-templating=true - enables helpers like {{now}} in all stub responses

The first property removes hardcoded ports from your project. That prevents a common local-dev failure where the mock server and your REST client drift out of sync.

The second property keeps time-based tests from turning stale. Hardcoded timestamps look harmless until six months pass and a test that once checked a future delivery date is now checking a date in the past. Response templating fixes that by generating values at request time.

Implementation

We are building a simple flow:

[
Simple Application Flow


](https://substackcdn.com/image/fetch/$s_!qDZG!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fc2c8810a-128e-456e-84a4-ff99b3fde63e_1240x2730.png)

Create the domain model

We need a small internal model first. This is the shape our own API returns. It is important to keep this separate from the carrier DTOs. External APIs change for their own reasons. Your internal model should not leak every naming choice and odd field shape from a vendor payload.

Create src/main/java/dev/mainthread/ShipmentStatusCode.java:

package dev.mainthread;

public enum ShipmentStatusCode {
    LABEL_CREATED,
    IN_TRANSIT,
    OUT_FOR_DELIVERY,
    DELIVERED,
    EXCEPTION
}
Enter fullscreen mode Exit fullscreen mode

Create src/main/java/dev/mainthread/ShipmentStatus.java:

package dev.mainthread;

import java.time.Instant;

public record ShipmentStatus(
        String trackingNumber,
        ShipmentStatusCode status,
        String lastLocation,
        Instant estimatedDelivery,
        String message) {
}
Enter fullscreen mode Exit fullscreen mode

This gives us one important guarantee: every consumer of our REST endpoint sees a stable domain response. It does not guarantee correctness of the downstream payload. If the carrier sends garbage, our service still needs to map or reject it safely. The type alone does not save you.

Create the carrier DTOs

Now we mirror the payload that the fictional carrier returns. These classes represent the integration boundary, not your business model.

Create src/main/java/dev/mainthread/dto/CarrierEvent.java:

package dev.mainthread.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

public record CarrierEvent(
        String timestamp,
        String location,
        @JsonProperty("event_code") String eventCode,
        String description) {
}
Enter fullscreen mode Exit fullscreen mode

Create src/main/java/dev/mainthread/dto/CarrierTrackingResponse.java:

package dev.mainthread.dto;

import java.util.List;

import com.fasterxml.jackson.annotation.JsonProperty;

public record CarrierTrackingResponse(
        @JsonProperty("tracking_number") String trackingNumber,
        @JsonProperty("status_code") String statusCode,
        @JsonProperty("status_message") String statusMessage,
        @JsonProperty("events") List<CarrierEvent> events,
        @JsonProperty("estimated_delivery") String estimatedDelivery) {
}
Enter fullscreen mode Exit fullscreen mode

This separation looks boring. It is also the part that keeps external drift from spreading through your codebase. If the carrier renames status_code or starts sending an extra field, you fix one boundary. You do not rewrite your whole application.

Create the REST client

We need the actual HTTP client that talks to the carrier. This is the component that WireMock will exercise in dev mode and tests.

Create src/main/java/dev/mainthread/CarrierClient.java:

package dev.mainthread;

import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

import dev.mainthread.dto.CarrierTrackingResponse;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@RegisterRestClient(configKey = "carrier-api")
@Path("/v1/track")
public interface CarrierClient {

    @GET
    @Path("/{trackingNumber}")
    @Produces(MediaType.APPLICATION_JSON)
    CarrierTrackingResponse track(@PathParam("trackingNumber") String trackingNumber);
}
Enter fullscreen mode Exit fullscreen mode

The important part is configKey = "carrier-api". That binds the client to quarkus.rest-client.carrier-api.url.

At first glance this is just a normal REST client. The difference is how we test it. We are not going to mock this interface in unit tests. We are going to let it make real HTTP calls to WireMock. That means path mapping, payload parsing, status handling, and request verification all stay visible.

Create the service layer

Now we need a service that maps the carrier response to our own domain model.

Create src/main/java/dev/mainthread/ShipmentService.java:

package dev.mainthread;

import java.time.Instant;

import org.eclipse.microprofile.rest.client.inject.RestClient;

import dev.mainthread.dto.CarrierTrackingResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.WebApplicationException;

@ApplicationScoped
public class ShipmentService {

    @Inject
    @RestClient
    CarrierClient carrierClient;

    public ShipmentStatus getStatus(String trackingNumber) {
        CarrierTrackingResponse response;
        try {
            response = carrierClient.track(trackingNumber);
        } catch (WebApplicationException e) {
            if (e.getResponse() != null && e.getResponse().getStatus() == 503) {
                return unavailable(trackingNumber);
            }
            throw e;
        }

        return new ShipmentStatus(
                response.trackingNumber(),
                mapStatusCode(response.statusCode()),
                extractLastLocation(response),
                parseDeliveryEstimate(response.estimatedDelivery()),
                response.statusMessage());
    }

    private ShipmentStatus unavailable(String trackingNumber) {
        return new ShipmentStatus(
                trackingNumber,
                ShipmentStatusCode.EXCEPTION,
                "unknown",
                null,
                "Carrier tracking temporarily unavailable");
    }

    private ShipmentStatusCode mapStatusCode(String carrierCode) {
        return switch (carrierCode) {
            case "LC" -> ShipmentStatusCode.LABEL_CREATED;
            case "IT" -> ShipmentStatusCode.IN_TRANSIT;
            case "OD" -> ShipmentStatusCode.OUT_FOR_DELIVERY;
            case "DL" -> ShipmentStatusCode.DELIVERED;
            default -> ShipmentStatusCode.EXCEPTION;
        };
    }

    private String extractLastLocation(CarrierTrackingResponse response) {
        if (response.events() == null || response.events().isEmpty()) {
            return "unknown";
        }
        return response.events().get(0).location();
    }

    private Instant parseDeliveryEstimate(String raw) {
        if (raw == null || raw.isBlank()) {
            return null;
        }
        try {
            return Instant.parse(raw);
        } catch (Exception e) {
            return null;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is where the real value sits. The service gives you a stable internal contract and a fallback for a known external failure. It does not protect you from every network problem. A 503 becomes a graceful degraded response. A connection reset still blows up. That is intentional for now. Good tutorials should leave visible edges where production hardening belongs.

Notice one design choice here: we translate carrier status codes immediately. We do not pass "IT" or "DL" through the rest of our application. That keeps vendor-specific semantics contained. It also means that when the carrier invents a new code, your system will degrade to EXCEPTION until you handle it explicitly. That is safer than pretending unknown values are valid.

Create the REST endpoint

Now expose the shipment status through your own API.

Create src/main/java/dev/mainthread/ShipmentResource.java:

package dev.mainthread;

import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/shipments")
@Produces(MediaType.APPLICATION_JSON)
public class ShipmentResource {

    @Inject
    ShipmentService shipmentService;

    @GET
    @Path("/{trackingNumber}")
    public ShipmentStatus track(@PathParam("trackingNumber") String trackingNumber) {
        return shipmentService.getStatus(trackingNumber);
    }
}
Enter fullscreen mode Exit fullscreen mode

This endpoint is thin on purpose. The resource handles HTTP. The service handles mapping and fallback logic. That separation matters because the tests we write later are really protecting the service behavior through the full HTTP stack.

Static JSON Stubs

Before we write any test code, let’s make the application work in dev mode against a mocked carrier API. This is the fastest feedback loop.

Create src/test/resources/mappings/track-in-transit.json:

{
    "request": {
        "method": "GET",
        "urlPathPattern": "/v1/track/SWIFT[0-9]+"
    },
    "response": {
        "status": 200,
        "bodyFileName": "track-in-transit-body.json",
        "headers": {
            "Content-Type": "application/json"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Create src/test/resources/__files/track-in-transit-body.json:

{
    "tracking_number": "SWIFT12345678",
    "status_code": "IT",
    "status_message": "Package is in transit",
    "estimated_delivery": "2026-03-20T18:00:00Z",
    "events": [
        {
            "timestamp": "2026-03-18T14:22:00Z",
            "location": "Memphis, TN",
            "event_code": "ARR",
            "description": "Arrived at sorting facility"
        },
        {
            "timestamp": "2026-03-17T09:10:00Z",
            "location": "Atlanta, GA",
            "event_code": "DEP",
            "description": "Departed origin facility"
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

The official Quarkus WireMock docs state that JSON stub files in mappings and __files are loaded from the root configured by quarkus.wiremock.devservices.files-mapping, which defaults to src/test/resources.

Start the application:

./mvnw quarkus:dev
Enter fullscreen mode Exit fullscreen mode

Now call your own API:

curl http://localhost:8080/shipments/SWIFT12345678 | jq
Enter fullscreen mode Exit fullscreen mode

Expected output:

{
  "trackingNumber": "SWIFT12345678",
  "status": "IN_TRANSIT",
  "lastLocation": "Memphis, TN",
  "estimatedDelivery": "2026-03-20T18:00:00Z",
  "message": "Package is in transit"
}
Enter fullscreen mode Exit fullscreen mode

This is already useful. Your Quarkus application is running against a mock server over real HTTP. No external credentials. No flaky sandbox. No hidden mock inside the JVM.

Programmatic Stubs with @ConnectWireMock

Static stubs are good for common development flows. Tests often need tighter control. That is where programmatic registration helps.

Create src/test/java/dev/mainthread/ShipmentResourceTest.java:

package dev.mainthread;

import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
import static io.restassured.RestAssured.given;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import com.github.tomakehurst.wiremock.client.WireMock;

import io.quarkiverse.wiremock.devservice.ConnectWireMock;
import io.quarkus.test.junit.QuarkusTest;

@QuarkusTest
@ConnectWireMock
class ShipmentResourceTest {

    WireMock wiremock;

    @BeforeEach
    void resetStubs() {
        wiremock.resetMappings();
    }

    @Test
    void returnsInTransitStatus() {
        wiremock.register(
                get(urlPathEqualTo("/v1/track/SWIFT99887766"))
                        .willReturn(aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody("""
                                            {
                                              "tracking_number": "SWIFT99887766",
                                              "status_code": "IT",
                                              "status_message": "Package is in transit",
                                              "estimated_delivery": "2026-03-20T18:00:00Z",
                                              "events": [
                                                {
                                                  "timestamp": "2026-03-18T14:22:00Z",
                                                  "location": "Memphis, TN",
                                                  "event_code": "ARR",
                                                  "description": "Arrived at sorting facility"
                                                }
                                              ]
                                            }
                                        """)));

        given()
                .when().get("/shipments/SWIFT99887766")
                .then()
                .statusCode(200)
                .body("status", equalTo("IN_TRANSIT"))
                .body("lastLocation", equalTo("Memphis, TN"))
                .body("message", equalTo("Package is in transit"));
    }
}
Enter fullscreen mode Exit fullscreen mode

@ConnectWireMock injects a WireMock client directly into the test class.

The important thing here is the reset in @BeforeEach. Without that, stubs leak between tests. That kind of pollution gives you the worst test failures: random ones. One test passes alone and fails in a suite. Always reset mappings or requests between tests unless you have a very good reason not to.

Also notice what we are testing. We are not asserting on the REST client directly. We call /shipments/... and let the whole stack run. That proves routing, outbound HTTP, JSON mapping, and our internal status mapping in one shot.

WireMock Scenarios - Simulating State Changes

This is where WireMock becomes much more useful than a plain mock object. Real APIs are not always stateless from your point of view. Shipment tracking is a good example. You call the same endpoint several times and the result changes over time.

Add this test to ShipmentResourceTest:

@Test
    void shipmentProgressesThroughLifecycle() {
        final String trackingNumber = "SWIFT55443322";
        final String path = "/v1/track/" + trackingNumber;
        final String scenario = "shipment-lifecycle";

        wiremock.register(
                get(urlPathEqualTo(path))
                        .inScenario(scenario)
                        .whenScenarioStateIs("Started")
                        .willReturn(aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody(carrierResponse(
                                        trackingNumber,
                                        "LC",
                                        "Label created, awaiting pickup",
                                        "unknown")))
                        .willSetStateTo("IN_TRANSIT"));

        wiremock.register(
                get(urlPathEqualTo(path))
                        .inScenario(scenario)
                        .whenScenarioStateIs("IN_TRANSIT")
                        .willReturn(aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody(carrierResponse(
                                        trackingNumber,
                                        "IT",
                                        "Package is in transit",
                                        "Chicago, IL")))
                        .willSetStateTo("OUT_FOR_DELIVERY"));

        wiremock.register(
                get(urlPathEqualTo(path))
                        .inScenario(scenario)
                        .whenScenarioStateIs("OUT_FOR_DELIVERY")
                        .willReturn(aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody(carrierResponse(
                                        trackingNumber,
                                        "OD",
                                        "Out for delivery",
                                        "Chicago, IL")))
                        .willSetStateTo("DELIVERED"));

        wiremock.register(
                get(urlPathEqualTo(path))
                        .inScenario(scenario)
                        .whenScenarioStateIs("DELIVERED")
                        .willReturn(aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody(carrierResponse(
                                        trackingNumber,
                                        "DL",
                                        "Package delivered",
                                        "Chicago, IL"))));

        given()
                .when().get("/shipments/" + trackingNumber)
                .then()
                .statusCode(200)
                .body("status", equalTo("LABEL_CREATED"));

        given()
                .when().get("/shipments/" + trackingNumber)
                .then()
                .statusCode(200)
                .body("status", equalTo("IN_TRANSIT"))
                .body("lastLocation", equalTo("Chicago, IL"));

        given()
                .when().get("/shipments/" + trackingNumber)
                .then()
                .statusCode(200)
                .body("status", equalTo("OUT_FOR_DELIVERY"));

        given()
                .when().get("/shipments/" + trackingNumber)
                .then()
                .statusCode(200)
                .body("status", equalTo("DELIVERED"));
    }

    private String carrierResponse(String trackingNumber, String code, String message, String location) {
        return """
                {
                  "tracking_number": "%s",
                  "status_code": "%s",
                  "status_message": "%s",
                  "estimated_delivery": "2026-03-20T18:00:00Z",
                  "events": [
                    {
                      "timestamp": "2026-03-18T14:22:00Z",
                      "location": "%s",
                      "event_code": "EVT",
                      "description": "%s"
                    }
                  ]
                }
                """.formatted(trackingNumber, code, message, location, message);
    }
Enter fullscreen mode Exit fullscreen mode

This test proves something important that a single happy-path stub cannot prove. It shows that your service maps status codes correctly across a full business lifecycle. It also shows the limit of this approach: scenario state lives inside WireMock, not your application. That means it is excellent for integration behavior, but it is not a substitute for real event ordering or persistence logic in your own code.

Response Templating

Hardcoded timestamps age badly. A good mock should stay useful next month, not just today.

Because we enabled global response templating in application.properties, we can generate time-based values dynamically.

Add this test:

 @Test
    void estimatedDeliveryIsInTheFuture() {
        wiremock.register(
                get(urlPathEqualTo("/v1/track/SWIFT11223344"))
                        .willReturn(aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody(
                                        """
                                                    {
                                                      "tracking_number": "SWIFT11223344",
                                                      "status_code": "IT",
                                                      "status_message": "In transit",
                                                      "estimated_delivery": "{{now offset='3 days' format='yyyy-MM-dd\\'T\\'HH:mm:ss\\'Z\\''}}",
                                                      "events": []
                                                    }
                                                """)));

        given()
                .when().get("/shipments/SWIFT11223344")
                .then()
                .statusCode(200)
                .body("estimatedDelivery", equalTo(org.hamcrest.Matchers.notNullValue().toString()));
    }
Enter fullscreen mode Exit fullscreen mode

That assertion is not great. Let’s fix it so it actually checks the field exists without becoming brittle:

    @Test
    void estimatedDeliveryIsPresent() {
        wiremock.register(
                get(urlPathEqualTo("/v1/track/SWIFT11223344"))
                        .willReturn(aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody(
                                        """
                                                    {
                                                      "tracking_number": "SWIFT11223344",
                                                      "status_code": "IT",
                                                      "status_message": "In transit",
                                                      "estimated_delivery": "{{now offset='3 days' format='yyyy-MM-dd\\'T\\'HH:mm:ss\\'Z\\''}}",
                                                      "events": []
                                                    }
                                                """)));

        given()
                .when().get("/shipments/SWIFT11223344")
                .then()
                .statusCode(200)
                .body("estimatedDelivery", org.hamcrest.Matchers.notNullValue());
    }
Enter fullscreen mode Exit fullscreen mode

This is much better. The test proves the date is generated and parsed. It does not overfit on a specific timestamp string.

Response templating is useful for more than time. You can also echo parts of the incoming request into the response. That is a simple way to make one stub handle many tracking numbers without copy-pasting dozens of files.

For example:

{
  "tracking_number": "{{request.pathSegments.[2]}}",
  "status_code": "IT",
  "status_message": "In transit",
  "estimated_delivery": "{{now offset='2 days' format='yyyy-MM-dd\\'T\\'HH:mm:ss\\'Z\\''}}",
  "events": []
}
Enter fullscreen mode Exit fullscreen mode

The value of response templating is not that it looks clever. The value is that it keeps your stubs alive as the calendar moves and your test data grows.

Fault Injection

This is the section most teams skip. It is also where the real operational value starts.

A service that only handles success is not production-ready. You need to know what happens when the carrier is down, when it responds slowly, or when the TCP connection breaks halfway through the call.

Handle 503 Service Unavailable

Add this test:

    @Test
    void returnsExceptionStatusWhenCarrierIsDown() {
        wiremock.register(
                get(urlPathEqualTo("/v1/track/SWIFT00000001"))
                        .willReturn(aResponse()
                                .withStatus(503)
                                .withHeader("Content-Type", "application/json")
                                .withBody("""
                                            {"error":"Service temporarily unavailable"}
                                        """)));

        given()
                .when().get("/shipments/SWIFT00000001")
                .then()
                .statusCode(200)
                .body("status", equalTo("EXCEPTION"))
                .body("message", equalTo("Carrier tracking temporarily unavailable"));
    }
Enter fullscreen mode Exit fullscreen mode

This test proves that your service degrades gracefully for one known downstream condition. That is good. It is not complete resilience. A graceful fallback for 503 does not help with timeouts or connection resets. Those are different failure modes and they need their own strategy.

Simulate a broken connection

Now add a network fault:

  @Test
    void handlesConnectionReset() {
        wiremock.register(
                get(urlPathEqualTo("/v1/track/SWIFT00000002"))
                        .willReturn(aResponse()
                                .withFault(com.github.tomakehurst.wiremock.http.Fault.CONNECTION_RESET_BY_PEER)));

        given()
                .when().get("/shipments/SWIFT00000002")
                .then()
                .statusCode(500);
    }
Enter fullscreen mode Exit fullscreen mode

This test documents a real gap. Right now the service knows how to map an HTTP 503, but it does not know how to recover from a low-level network failure. That is not a problem with WireMock. That is exactly the behavior your code has today. The value of the test is that the gap is now visible and repeatable.

WireMock can simulate several network-level failures, including:

  • CONNECTION_RESET_BY_PEER

  • EMPTY_RESPONSE

  • MALFORMED_RESPONSE_CHUNK

  • RANDOM_DATA_THEN_CLOSE

Those are the kinds of failures that are painful to trigger against a real third-party API and trivial to reproduce with a good stub server.

Simulate latency

You should also test slow downstream responses.

   @Test
    void slowCarrierResponseCanTriggerTimeouts() {
        wiremock.register(
                get(urlPathEqualTo("/v1/track/SWIFT00000003"))
                        .willReturn(aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withFixedDelay(2000)
                                .withBody("""
                                            {
                                              "tracking_number": "SWIFT00000003",
                                              "status_code": "IT",
                                              "status_message": "In transit",
                                              "estimated_delivery": "2026-03-20T18:00:00Z",
                                              "events": []
                                            }
                                        """)));

        given()
                .when().get("/shipments/SWIFT00000003")
                .then()
                .statusCode(200);
    }
Enter fullscreen mode Exit fullscreen mode

Right now this still returns 200 because we have not configured a client timeout. That is fine. The test gives us a base line. Once you add a read timeout, this same setup becomes your timeout test.

Production Hardening

What happens under load

Mocking with WireMock does not remove pressure from your own service. Your REST client still allocates connections, parses JSON, and handles errors on request threads. If the carrier is slow and your timeout is too generous, your application ties up resources waiting on something it cannot control.

Fast failure is usually better than patient failure for external dependencies. If the carrier is down, you want a short timeout and a clear fallback. You do not want every request thread waiting 30 seconds because the remote side stopped responding.

Add this to src/main/resources/application.properties if you want a stricter production-style setup:

quarkus.rest-client.carrier-api.connect-timeout=1000
quarkus.rest-client.carrier-api.read-timeout=1000
Enter fullscreen mode Exit fullscreen mode

Now a delayed mock becomes a deterministic timeout test instead of a slow success path.

Concurrency and ordering

WireMock scenarios are useful, but they are not a source of truth for concurrency guarantees. They simulate sequential state transitions in the mock server. They do not prove that your own application handles concurrent reads, repeated polls, or duplicate events correctly.

For example, two callers can still ask for the same tracking number at the same time. Your service will make two outbound calls unless you add caching or request collapsing. Mocking alone does not fix chatty client behavior.

That is why request verification matters. It tells you how many times your code actually hit the downstream API. Once you add caching later, your verification test should change with it.

Failure boundaries

Right now our service handles one failure cleanly and exposes another one as 500. That is honest. It is also the signal to add proper fault tolerance.

In a real production service, I would usually add the quarkus-smallrye-fault-tolerance extension and then apply retry, timeout, or circuit breaker behavior at the service boundary. The goal is not to “hide” all failures. The goal is to fail in a controlled way and stop a broken dependency from dragging your service down with it.

Security and abuse cases

Mock servers are easy to trust too much. A mock that always returns perfect JSON trains your code to expect polite input. Real external systems do not behave that way forever.

At minimum, think about these cases:

  • invalid or unexpected status codes

  • missing events

  • malformed timestamps

  • very large payloads

  • repeated requests for the same tracking number

Our ShipmentService already degrades unknown status codes to EXCEPTION and treats bad timestamps as null. That is a good start. It is not enough for every abuse case, but it prevents obvious parsing failures from crashing the whole mapping layer.

Request Verification

One of the best things about WireMock is that it records what your client actually sent. That lets you verify behavior, not just response content.

Add this test:

   @Test
    void callsCarrierApiExactlyTwiceWithoutCaching() {
        wiremock.register(
                get(urlPathEqualTo("/v1/track/SWIFT77665544"))
                        .willReturn(aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody(carrierResponse(
                                        "SWIFT77665544",
                                        "DL",
                                        "Delivered",
                                        "Austin, TX"))));

        given().when().get("/shipments/SWIFT77665544").then().statusCode(200);
        given().when().get("/shipments/SWIFT77665544").then().statusCode(200);

        wiremock.verifyThat(
                exactly(2),
                getRequestedFor(urlPathEqualTo("/v1/track/SWIFT77665544")));
    }
Enter fullscreen mode Exit fullscreen mode

This proves that there is no caching in place yet. That is useful. It tells you the current operational cost of the endpoint.

Later, if you add a cache in ShipmentService, this test should change to exactly(1). That is a good example of a test that evolves with design, not against it.

You can also assert that a request never happened:

wiremock.verifyThat(
    never(),
    getRequestedFor(urlPathEqualTo("/v1/track/SWIFT99999999"))
);
Enter fullscreen mode Exit fullscreen mode

That becomes useful once you add circuit breakers or short-circuit logic.

Verification

Run the tests

Run the full test suite:

./mvnw test
Enter fullscreen mode Exit fullscreen mode

Expected result:

[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
Enter fullscreen mode Exit fullscreen mode

This verifies that your Quarkus endpoint, service layer, REST client, and WireMock stubs work together in test mode.

Inspect the WireMock admin endpoint

The WireMock admin API is useful when you want to see registered stubs and recorded requests.

You can go to the Quarkus Dev UI to find the wiremock mappings. If you click on the tile mappings url you can see them directly:

http://localhost:<wiremock-port>/__admin/mappings
Enter fullscreen mode Exit fullscreen mode

This is useful for debugging stub registration problems, path mismatches, or unexpected requests from your client.

Subscribe now

Conclusion

We built a Quarkus shipment tracking feature that talks to a mocked carrier API over real HTTP, not a fake in-memory method call. Static stubs gave us fast local development. Programmatic stubs let us target exact cases. Scenarios simulated shipment lifecycle changes, response templating removed stale test dates, fault injection exposed resilience gaps, and request verification proved how the client actually behaved. The main point is simple: WireMock is not just for fake responses. It is a controlled failure boundary for integrations you do not own.

Share

Top comments (0)