Stubbing Third-Party APIs With WireMock in Compose
The API container calls the shipping partner through WireMock and gets 404 with the body No response could be served as there are no stub mappings in this WireMock instance, or Request was not matched followed by a near-miss diff that shows the stub expected /v2/quotes and the client sent /v2/quotes/. Or everything works, but only the happy path is stubbed, so the code that handles the partner's 429 and timeout responses has never run anywhere except production. This page sets up WireMock in Compose with mappings that match reliably and cover the failure modes that matter, as part of mocking external APIs and third-party services.
WireMock is the right tool when you need exact control over responses: specific error codes, delays, sequences and assertions about what the application sent.
Diagnostic
Check what WireMock has loaded and what it received that matched nothing:
#!/usr/bin/env bash
set -euo pipefail
curl -fsS http://localhost:8089/__admin/mappings | jq '.meta.total'
docker compose exec -T shipping-mock ls -R /home/wiremock | head -8
curl -fsS http://localhost:8089/__admin/requests/unmatched | jq -r '.requests[] | "\(.method) \(.url)"' | sort | uniq -c
curl -fsS http://localhost:8089/__admin/requests/unmatched/near-misses | jq -r '.nearMisses[0].matchResult.distance // "none"'
Expected bad output:
0
/home/wiremock:
__files
mocks
3 POST /v2/quotes/
none
WireMock loaded zero mappings because they were mounted into /home/wiremock/mocks instead of /home/wiremock/mappings, and the client sends a trailing slash the stubs would not have matched anyway.
Root cause
WireMock loads stub definitions from a mappings directory and response bodies from __files under its root (/home/wiremock in the official image). Mounting a directory at any other path, or a JSON syntax error in one file, leaves it with zero mappings and every request returns the generic 404. Once mappings load, matching is exact by default: url matches the full path and query string exactly, so a trailing slash, an extra query parameter or different parameter order all miss. urlPath ignores the query, urlPathPattern accepts a regex, and body matchers such as equalToJson compare structure rather than bytes. Near-miss reports exist precisely because these small mismatches are the common failure; reading them is faster than guessing.
Response templating adds a second, quieter failure mode. With --global-response-templating enabled, any {{ ... }} sequence in a response body is interpreted as a Handlebars expression. A copied real response that happens to contain double braces — a templated email body, a Mustache fragment in a CMS payload — then renders as empty or throws a template error, and the stub appears to return the wrong data. Either disable templating globally and enable it per mapping with "transformers": ["response-template"], or escape the braces in those bodies.
The happy-path-only problem is a different kind of cause: stubs are usually written by copying one real response, so failure behaviour is never modelled. The partner's rate limiting, timeouts and validation errors then exist only in production.
Resolution
- Mount mappings at the path WireMock reads and give the service a healthcheck:
services:
shipping-mock:
image: wiremock/wiremock:3.9.1
command: ["--global-response-templating", "--disable-banner", "--verbose"]
ports:
- "127.0.0.1:8089:8080"
volumes:
- ./mocks/shipping/mappings:/home/wiremock/mappings:ro
- ./mocks/shipping/__files:/home/wiremock/__files:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/__admin/health"]
interval: 5s
retries: 20
- Match on what matters, tolerantly. Use
urlPathPatternfor paths that may carry a trailing slash, and JSON-path matchers for bodies:
{
"name": "quote success",
"request": {
"method": "POST",
"urlPathPattern": "/v2/quotes/?",
"headers": { "Authorization": { "matches": "Bearer .+" } },
"bodyPatterns": [{ "matchesJsonPath": "$[?(@.destination.country == 'DE')]" }]
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "quote_id": "{{randomValue type='UUID'}}", "amount_cents": 1299, "currency": "EUR" }
}
}
- Add failure scenarios as separate mappings, selected by a test-controlled input such as a magic country code:
{
"name": "quote rate limited",
"request": { "method": "POST", "urlPathPattern": "/v2/quotes/?", "bodyPatterns": [{ "matchesJsonPath": "$[?(@.destination.country == 'XR')]" }] },
"response": { "status": 429, "headers": { "Retry-After": "2" }, "jsonBody": { "error": "rate_limited" } }
}
{
"name": "quote timeout",
"request": { "method": "POST", "urlPathPattern": "/v2/quotes/?", "bodyPatterns": [{ "matchesJsonPath": "$[?(@.destination.country == 'XT')]" }] },
"response": { "status": 200, "fixedDelayMilliseconds": 15000, "jsonBody": { "quote_id": "late" } }
}
- Model sequences with scenarios when the partner's state changes between calls, such as a shipment moving from
createdtoin_transit:
{
"scenarioName": "shipment lifecycle",
"requiredScenarioState": "Started",
"newScenarioState": "in_transit",
"request": { "method": "GET", "urlPathPattern": "/v2/shipments/[a-z0-9-]+" },
"response": { "status": 200, "jsonBody": { "status": "created" } }
}
A second mapping with "requiredScenarioState": "in_transit" returns the next state. Tests reset scenarios with POST /__admin/scenarios/reset.
- Point the application at the mock through its base URL variable and assert on received requests in tests:
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --wait shipping-mock api
curl -fsS -X POST http://localhost:8089/__admin/requests/count \
-H 'content-type: application/json' -d '{"method": "POST", "urlPathPattern": "/v2/quotes/?"}' | jq '.count'
Expected output
$ curl -fsS http://localhost:8089/__admin/mappings | jq '.meta.total'
6
$ curl -fsS -X POST http://localhost:8089/v2/quotes/ -H 'Authorization: Bearer test' -H 'content-type: application/json' -d '{"destination":{"country":"DE"}}'
{"quote_id":"5e0c7a1b-7a3e-4c61-9d8e-2f1b0e6c9a44","amount_cents":1299,"currency":"EUR"}
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8089/v2/quotes -H 'Authorization: Bearer test' -H 'content-type: application/json' -d '{"destination":{"country":"XR"}}'
429
All mappings load, requests match with or without the trailing slash, and failure scenarios are reachable on demand — so the client's retry and timeout handling can be tested locally.
Run the application's own integration tests against the mock and then check the unmatched-requests endpoint once more. An empty list at the end of a full test run means every call the application makes is covered by a mapping; any entry is either a missing scenario or a client bug, and both are worth knowing about before a release.
Prevention
Fail tests on unmatched requests. After the integration suite, query
/__admin/requests/unmatchedand fail if it is not empty; unmatched calls mean the client changed and the stubs did not.Validate mapping JSON in CI (
jq empty mocks/**/mappings/*.json) so a syntax error does not silently leave WireMock with no mappings.Add a mapping for every production incident involving the partner, reproducing the behaviour that caused it.
Platform caveats
Apple Silicon (ARM64):
wiremock/wiremockimages are multi-arch and run natively.
macOS (Docker Desktop): mapping files are read at startup and on
POST /__admin/mappings/reset; bind-mounted edits do not reload automatically. Call the reset endpoint after editing stubs, or restart the container.
WSL2: keep the
mocks/directory in the Linux filesystem; mounting from/mnt/cworks but CRLF line endings in JSON bodies can surprise exact-match body stubs.
Rollback
Point the base URL back at the partner's sandbox and remove the service; mapping files can stay for later use:
#!/usr/bin/env bash
set -euo pipefail
docker compose rm -sf shipping-mock
sed -i.bak 's|^SHIPPING_BASE_URL=.*|SHIPPING_BASE_URL=https://sandbox.shipfast.example|' .env
Frequently Asked Questions
Why does WireMock say there are no stub mappings?
Mappings were not loaded, usually because they are mounted at the wrong path or one file is invalid JSON. Mount them at /home/wiremock/mappings and check GET /__admin/mappings for the count.
How do I match a URL regardless of query parameters?
Use urlPath or urlPathPattern instead of url, and add queryParameters matchers only for parameters that matter. url requires an exact match of path and query string.
Can WireMock simulate slow or broken responses?
Yes. fixedDelayMilliseconds and delayDistribution add latency, and fault values such as CONNECTION_RESET_BY_PEER and MALFORMED_RESPONSE_CHUNK simulate network failures.
How do tests select a failure scenario?
Use a request attribute the test controls — a magic value in the body, a header such as X-Mock-Scenario, or a path segment — and give each failure mapping a matcher on it. Keep the magic values documented next to the mappings.