Mocking External APIs and Third-Party Services
Almost every product depends on APIs the team does not run: a payment provider, an identity provider, a shipping partner, an internal platform service owned by another team. Locally, each of those dependencies forces a bad choice. Calling the real sandbox needs credentials on every laptop, shares rate limits and test data between developers, fails when the network or the provider is down, and cannot produce the error cases that matter most — a declined card, a timeout, a malformed response. Stubbing the client in code keeps tests fast but skips the HTTP layer, serialisation and error handling where integration bugs actually live. This topic, part of environment sync, secrets and CI parity, covers the middle path: HTTP-level stand-ins for external APIs, run as Compose services, driven by the same contracts the real providers publish, and checked so they do not drift from reality.
The principle mirrors the one behind emulating cloud services locally: the application should not know it is talking to a stand-in. It sends real HTTP requests to a base URL from configuration; locally that URL points at a mock server, in staging at the provider's sandbox, and in production at the live API. Everything between the application's HTTP client and the network stays identical, which is exactly the code that code-level stubs bypass.
Four techniques cover most needs, and a mature setup uses several of them. Hand-written stubs (WireMock, Mockoon) for precise control over specific scenarios. Contract-generated mocks (Prism from an OpenAPI document) for broad coverage with no hand-written responses. Record and replay for APIs without a usable specification, capturing real traffic once and serving it back. And webhook simulation for the inbound half of integrations, where the provider calls you. The last section — keeping mocks honest — applies to all of them.
Prerequisites
- Base URLs from configuration. Every external client must read its base URL (and ideally timeouts) from environment variables, not constants. This is the one code change the approach needs, and it is worth making anyway.
- Docker Engine 24+ and Compose v2.20+, with a few hundred megabytes of memory for mock servers; WireMock's JVM is the heaviest at about 200–300 MB.
- API descriptions where they exist: OpenAPI documents for internal services and for providers that publish them (Stripe, GitHub, Twilio and many others do).
- Sandbox credentials for a small set of CI contract tests that run against real providers on a schedule, stored in the CI secret store rather than on laptops.
Check that clients are configurable before adding any mock:
#!/usr/bin/env bash
set -euo pipefail
git grep -nE "https://api\.(stripe\.com|github\.com|shipfast\.example)" -- 'src/**' ':!src/**/*.test.*' \
&& { echo "hard-coded external base URLs found; move them to configuration"; exit 1; } \
|| echo "external base URLs come from configuration"
grep -E '^(STRIPE|GITHUB|SHIPFAST)_.*URL=' .env.example
Hand-written stubs with WireMock
WireMock serves responses from JSON mapping files that match on method, path, headers, query and body. It is the right tool when you need precise control: a specific decline code for a specific card number, a 30-second delay to test a timeout, a sequence of responses that simulates a retry. Running it as a Compose service with mappings in the repository makes every scenario reviewable and shared:
services:
shipping-mock:
image: wiremock/wiremock:3.9.1
command: ["--global-response-templating", "--disable-banner"]
ports:
- "127.0.0.1:8089:8080"
volumes:
- ./mocks/shipping:/home/wiremock:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/__admin/health"]
interval: 5s
retries: 20
api:
environment:
SHIPPING_BASE_URL: http://shipping-mock:8080
depends_on:
shipping-mock:
condition: service_healthy
{
"request": { "method": "POST", "urlPath": "/v2/quotes", "bodyPatterns": [{ "matchesJsonPath": "$.destination.country" }] },
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "quote_id": "{{randomValue type='UUID'}}", "amount_cents": 1299, "currency": "EUR" }
}
}
- Put mappings in
mocks/<service>/mappings/*.json, one file per scenario, named after the behaviour it represents. - Add failure scenarios deliberately — timeouts, 429s, 500s — not just the happy path.
- Use the admin API in tests to reset state and verify which requests were received.
Treat the mappings directory as test code. Scenario names such as quote-success.json, quote-unsupported-country.json and quote-timeout.json document the behaviours the application is expected to handle, and a reviewer can see at a glance which failure modes are covered. When a production incident reveals a new provider behaviour — an undocumented error code, a field that is sometimes null — the fix includes a new mapping that reproduces it, so the regression is covered locally from then on. Over time the stub directory becomes the most accurate description of how the provider really behaves, which is valuable for onboarding in its own right.
The drift diagnostic for this section is WireMock's near-miss report: requests that matched no mapping, which usually mean the application changed its calls and the stubs did not. The WireMock guide covers scenarios, stateful sequences and fault injection.
#!/usr/bin/env bash
set -euo pipefail
curl -fsS http://localhost:8089/__admin/requests/unmatched | jq -r '.requests[] | "\(.method) \(.url)"' | sort | uniq -c
Mocks generated from OpenAPI
Hand-written stubs cover the scenarios someone thought of. A mock generated from the provider's OpenAPI document covers every documented endpoint immediately, returns responses that match the schema, and validates the application's requests against the specification — so a request with a missing required field fails locally, the way it would against the real API. Prism is the most common tool for this:
services:
payments-mock:
image: stoplight/prism:5.12.0
command: ["mock", "-h", "0.0.0.0", "-p", "4010", "--errors", "/specs/payments.yaml"]
volumes:
- ./specs:/specs:ro
api:
environment:
PAYMENTS_BASE_URL: http://payments-mock:4010
--errors makes Prism reject requests that violate the specification with a 422 and a description of the violation, which turns contract mistakes into immediate local failures. Prism can also return specific examples from the document with a Prefer: example=declined header, which makes specific scenarios reachable without hand-written stubs. The specification is only as good as its source. Pin the provider's published document at a specific version in the repository rather than fetching it at startup, both so the mock does not change underneath running tests and so an update becomes a reviewable diff. Provider specifications are sometimes looser than the real API — fields documented as optional that are always present, enums missing new values — so pair the generated mock with a few hand-written stubs for the responses that matter most to the application.
The Prism guide covers dynamic responses, examples and validation proxy mode against a real sandbox.
Record and replay
Many APIs — partner integrations, legacy internal services — have no specification and no convenient sandbox. Record and replay captures real responses once, through a proxy, and serves them back afterwards. Tests become deterministic and offline, and they exercise real response shapes rather than someone's idea of them. WireMock's record mode and tools such as Hoverfly or VCR-style libraries work this way:
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d shipping-mock
curl -fsS -X POST http://localhost:8089/__admin/recordings/start \
-H 'content-type: application/json' \
-d '{"targetBaseUrl": "https://sandbox.shipfast.example", "captureHeaders": {"Accept": {}}, "requestBodyPattern": {"matcher": "equalToJson", "ignoreExtraElements": true}}'
SHIPPING_BASE_URL=http://localhost:8089 npm run test:integration -- --grep shipping
curl -fsS -X POST http://localhost:8089/__admin/recordings/stop | jq '.mappings | length'
Recording works best for read-heavy integrations where responses depend mainly on the request: catalogue lookups, address validation, rate quotes. It works poorly for flows where the provider's state changes between calls — creating and then fetching an order — unless the recording tool captures the sequence as a stateful scenario, which WireMock's recorder can do when repeated identical requests return different responses. Decide per integration whether replay is faithful enough, and fall back to hand-written scenarios where it is not.
Recorded responses contain whatever the sandbox returned, including tokens, customer-like data and timestamps. Scrub them before committing — the record and replay guide shows how — and re-record on a schedule, since the recording is a snapshot that ages.
Inbound webhooks
Integrations are two-way. Payment providers, source-control platforms and messaging services call your application back with webhooks, and those calls cannot reach a laptop from the internet. The provider CLIs solve part of this — stripe listen --forward-to localhost:8080/webhooks/stripe relays real test-mode events and signs them — but local development also needs to send arbitrary events on demand, including rare ones like disputes and failed renewals, with valid signatures so the application's verification code runs. A small signed-event sender does that:
#!/usr/bin/env bash
set -euo pipefail
secret="${STRIPE_WEBHOOK_SECRET:?set STRIPE_WEBHOOK_SECRET}"
payload=$(cat mocks/webhooks/stripe/invoice.payment_failed.json)
ts=$(date +%s)
sig=$(printf '%s.%s' "$ts" "$payload" | openssl dgst -sha256 -hmac "$secret" | awk '{print $2}')
curl -fsS -X POST http://localhost:8080/webhooks/stripe \
-H 'content-type: application/json' \
-H "Stripe-Signature: t=$ts,v1=$sig" \
--data "$payload" -w '\nstatus %{http_code}\n'
Signing locally means the verification path is always exercised rather than disabled "for development". The webhook secret used here is a development value in the local env file, distinct from the production signing secret; the point is to run the same verification code, not to share keys.
Webhook fixtures deserve the same care as response stubs. Keep one file per event type the application handles, taken from the provider's documentation or captured from test mode and scrubbed, and name them after the event (invoice.payment_failed.json, pull_request.opened.json). A missing fixture for an event type the code claims to handle is a gap in local testing that usually corresponds to a handler nobody has run since it was written. Sending each fixture twice in a row is a cheap way to confirm handlers are idempotent, since providers routinely redeliver events. The webhook simulation guide covers GitHub's HMAC scheme, replaying captured production-shaped events, and testing idempotency by sending the same event twice.
Keeping mocks honest
A mock that no longer matches the real API is worse than none: tests pass against a fiction, and the integration fails in production. Three checks keep stand-ins honest. Validate hand-written stubs against the provider's OpenAPI document, so a response shape the real API cannot produce is caught in CI. Run a small suite of contract tests against the real sandbox on a schedule, and compare the responses' structure with the mocks'. And for internal services, use consumer-driven contracts (Pact), where the consuming team's expectations are verified against the providing team's service in its own pipeline:
Prism's proxy mode is a practical way to validate hand-written stubs: it sits between the tests and the WireMock stub, forwards every request, and fails any request or response that violates the specification. Running the integration suite through it in CI turns the stubs into something checked rather than trusted:
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --wait payments-stub
docker run -d --rm --name prism-validate --network "$(basename "$PWD")_default" -p 4011:4010 \
-v "$PWD/specs:/specs:ro" stoplight/prism:5.12.0 proxy -h 0.0.0.0 --errors /specs/payments.yaml http://payments-stub:8080
PAYMENTS_BASE_URL=http://localhost:4011 npm run test:integration -- --grep payments
docker logs prism-validate 2>&1 | grep -E 'violation|error' && { echo "stubs violate the specification"; exit 1; } || echo "stubs conform to the specification"
docker rm -f prism-validate
Contract checks work best when they are cheap enough to run on every pull request that touches a mock or a client. The proxy validation above adds seconds to an integration run, so it can be part of the normal pipeline. The sandbox comparison is slower and depends on the provider's availability, so it runs on a schedule and opens an issue rather than blocking merges. That split — fast structural checks always, slow behavioural checks periodically — keeps the checks from becoming the kind of flaky gate teams learn to ignore.
The contract sync guide builds the scheduled sandbox comparison and the Pact workflow, and ties them into the site's consolidated CI parity checks.
Platform caveats
Apple Silicon (ARM64): WireMock, Prism and Mockoon publish multi-arch images. Hoverfly publishes arm64 binaries; older record-and-replay tools may only ship amd64 images that run under emulation.
macOS (Docker Desktop): mock servers are reached by service name from containers and by published port from the host. Keep two base URL values — one in Compose, one in
.envfor host-side test runs — rather than routing containers throughhost.docker.internal.
WSL2: provider CLIs such as
stripe listenshould run where the application listens. If the application runs in Compose, forward to the published port from WSL; if it runs natively on Windows, run the CLI on Windows.
Rollback and recovery
Stand-ins are configuration. To return to the real sandbox for one dependency, point its base URL back at the provider and supply sandbox credentials through the local secret workflow; no application code changes are needed. To reset a mock that has accumulated state, restart its container or call its reset endpoint:
#!/usr/bin/env bash
set -euo pipefail
curl -fsS -X POST http://localhost:8089/__admin/reset
docker compose restart payments-mock
Frequently Asked Questions
Why not mock the API client in unit tests instead?
Unit-level mocks are fine for business logic, but they skip the HTTP client, serialisation, headers, retries and error parsing — the code most likely to break against a real API. HTTP-level stand-ins exercise all of it while staying fast and offline.
WireMock or Prism?
Use Prism when an OpenAPI document exists and you want broad, schema-valid coverage with request validation. Use WireMock when you need precise scenarios, sequences or fault injection. Many stacks use both for different dependencies.
Is it safe to commit recorded responses?
Only after scrubbing. Recordings contain whatever the sandbox returned, including tokens and personal-looking data. Replace sensitive values with placeholders and run the secret scanner over the mocks directory.
How do we know a mock is still accurate?
Validate stubs against the provider's specification in CI, run a small contract suite against the real sandbox on a schedule, and re-record recordings periodically. A mock without any such check should be assumed stale.
Related
- Stub a partner API with WireMock in Compose
- Generate a validating mock from OpenAPI
- Emulate cloud provider services the same way
- Match local seed data to CI fixtures
Every guide in this topic
- Generating Mock Servers From OpenAPI With PrismServe schema-valid mock responses from an OpenAPI document with Prism, validate your client's requests, pick examples per test, and proxy to catch drift.
- Keeping API Mocks in Sync With Provider ContractsStop mocks drifting from real APIs: validate stubs against OpenAPI, run scheduled sandbox contract tests, and verify internal services with Pact consumer contracts.
- Recording and Replaying HTTP Traffic for Local TestsCapture a partner API's real responses once with WireMock's recorder, scrub tokens and personal data, replay them offline in tests, and re-record on a schedule.
- Simulating Stripe and GitHub Webhooks LocallyDeliver signed Stripe and GitHub webhook events to a local stack: stripe listen forwarding, locally signed fixtures and signature verification fixes.
- Stubbing Third-Party APIs With WireMock in ComposeReplace a partner API with WireMock in Docker Compose: mapping files, failure and timeout scenarios, stateful sequences, and fixing 404 No response could be served.