Mock External APIs in Quarkus with WireMock: A Hands-On Guide
Build and test a shipment tracking service end to end with Quarkus REST Client, WireMock Dev Services, fault injection, scenarios, and request verification.
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-demoWe 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 endpointrest-jackson- serializes and deserializes JSONrest-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>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=trueHere is what each setting does:
quarkus.rest-client.carrier-api.url- points the REST client at the WireMock server started by the Dev Servicequarkus.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:
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
}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) {
}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) {
}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) {
}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);
}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;
}
}
}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);
}
}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"
}
}
}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"
}
]
}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:devNow call your own API:
curl http://localhost:8080/shipments/SWIFT12345678 | jqExpected output:
{
"trackingNumber": "SWIFT12345678",
"status": "IN_TRANSIT",
"lastLocation": "Memphis, TN",
"estimatedDelivery": "2026-03-20T18:00:00Z",
"message": "Package is in transit"
}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"));
}
}@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);
}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()));
}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());
}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": []
}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"));
}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);
}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_PEEREMPTY_RESPONSEMALFORMED_RESPONSE_CHUNKRANDOM_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);
}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=1000Now 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
eventsmalformed 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")));
}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"))
);That becomes useful once you add circuit breakers or short-circuit logic.
Verification
Run the tests
Run the full test suite:
./mvnw testExpected result:
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0This 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/mappingsThis is useful for debugging stub registration problems, path mismatches, or unexpected requests from your client.
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.




Very, very useful!
Thank you!