Mock Lifecycle Management
This page covers how to provision, synchronise, and tear down mock servers deterministically — from the first startup probe in CI to graceful shutdown and request-log archival. It does not cover the selection of mocking tools or handler authoring; those topics live in the tool-specific implementation guides.
Prerequisites
- Docker Engine 24+ and Docker Compose v2 installed locally
- WireMock 3.x image available (
wiremock/wiremock:3.13.2or later) — or an equivalent mock server with an admin HTTP API - A CI runner with Docker socket access (GitHub Actions
ubuntu-latestsatisfies this) - Familiarity with the proxy vs inline mocking trade-offs — your choice determines whether phases are process-bound or network-bound
- At least one stub mapping file (JSON or YAML) ready to mount into the mock container
Phase 1 — Lifecycle Phases and Startup Probes
A production-grade mock server lifecycle has four distinct phases: initialisation, active routing, state mutation, and graceful teardown. The division of responsibilities between these phases is not cosmetic — it is the mechanism that prevents race conditions where tests execute against a partially-started server.
The startup boundary is the most common source of flaky integration tests. The only reliable approach is a readiness probe that validates the server’s own health endpoint before allowing the test runner to proceed. For WireMock, that endpoint is /__admin/health.
The following startup wrapper script is safe for both local shells and CI runners:
#!/usr/bin/env bash
# scripts/wait-for-mock.sh
# Usage: ./scripts/wait-for-mock.sh http://localhost:8080
set -euo pipefail
MOCK_URL="${1:-http://localhost:8080}"
HEALTH_URL="${MOCK_URL}/__admin/health"
MAX_ATTEMPTS=30
INTERVAL=2
echo "Waiting for mock server at ${HEALTH_URL}..."
for i in $(seq 1 "${MAX_ATTEMPTS}"); do
if curl -sf "${HEALTH_URL}" > /dev/null 2>&1; then
echo "Mock server ready after ${i} probe(s)."
exit 0
fi
echo " Probe ${i}/${MAX_ATTEMPTS} — not ready yet, retrying in ${INTERVAL}s..."
sleep "${INTERVAL}"
done
echo "ERROR: Mock server did not become healthy within $((MAX_ATTEMPTS * INTERVAL))s"
exit 1
The set -euo pipefail header ensures the script exits immediately if any probe command itself fails with an unexpected error, rather than silently looping until the attempt limit is reached.
Inline vs standalone phase orchestration: how these phases are wired depends on your choice of interception strategy. Inline mocks (running inside the test process via MSW Node handler or Jest mocks) share the host process lifecycle — their “initialisation” phase is the beforeAll hook, and teardown runs in afterAll. Standalone mock servers (WireMock, Prism) have network-level lifecycle boundaries requiring explicit startup probes and teardown signals. The trade-offs between these approaches are covered in depth in the proxy vs inline mocking strategies guide.
Phase 2 — Container Configuration and Environment Wiring
Standalone mock servers require explicit container definitions with version-pinned images, isolated bridge networks, read-only volume mounts, and resource bounds. Each of these properties enforces a specific lifecycle guarantee:
- Version-pinned image tags (
wiremock:3.13.2notwiremock:latest) prevent silent stub-format changes from breaking CI after an upstream image update - Read-only volume mounts (
:ro) prevent test execution from accidentally mutating the source-of-truth stub definitions on disk - Isolated bridge networks prevent cross-job port collisions in parallel CI matrix runs
- Resource limits cap memory and CPU so a misbehaving mock container cannot starve the test runner
# docker-compose.mock.yml
services:
mock-gateway:
image: wiremock/wiremock:3.13.2
ports:
- "8080:8080"
networks:
- mock-net
volumes:
- ./wiremock/mappings:/home/wiremock/mappings:ro
- ./wiremock/__files:/home/wiremock/__files:ro
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"
environment:
- WIREMOCK_OPTIONS=--global-response-templating --max-request-journal-size=500
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"]
interval: 5s
timeout: 3s
retries: 6
start_period: 10s
networks:
mock-net:
driver: bridge
The start_period of 10 seconds is important for WireMock specifically: the JVM typically takes 4–8 seconds to reach full readiness, and without a start_period the health check’s retry budget is consumed before the process is capable of responding.
Environment variable injection: any value that changes between local, CI, and staging environments (base URL, API version, feature flags) must flow in via environment variables rather than being hardcoded in stub files. Use a .env.mock file locally and CI secret injection in pipelines:
# .env.mock (local development only — gitignored)
MOCK_PORT=8080
MOCK_API_VERSION=v2
WIREMOCK_OPTIONS=--global-response-templating --verbose
# docker-compose.mock.yml (extended for env-var injection)
services:
mock-gateway:
env_file:
- .env.mock
For dockerized mock environments that involve multiple services, namespace each mock container’s network to its own bridge to prevent cross-service route bleed during parallel execution.
Phase 3 — CI/CD Integration and State Seeding
The active routing and state mutation phases are where lifecycle management intersects with request interception patterns — how middleware captures traffic determines which lifecycle hooks fire and in what order.
In CI, the lifecycle sequence is:
- Start the mock container (Docker service or
docker compose up -d) - Block the test runner until the health probe passes
- Seed scenario state via the admin API
- Execute the test suite
- Export the request journal for regression artefacts
- Tear down cleanly on success or failure (using
if: always()in GitHub Actions)
# .github/workflows/integration-test.yml
name: Integration Tests
on: [push, pull_request]
jobs:
integration-tests:
runs-on: ubuntu-latest
services:
mock-api:
image: wiremock/wiremock:3.13.2
ports:
- 8080:8080
options: >-
--health-cmd "curl -sf http://localhost:8080/__admin/health || exit 1"
--health-interval 5s
--health-timeout 3s
--health-retries 6
--health-start-period 10s
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Seed checkout-failure scenario
run: |
curl -sf -X POST http://localhost:8080/__admin/mappings \
-H "Content-Type: application/json" \
-d @./wiremock/scenarios/checkout-failure.json
- name: Seed payment-timeout scenario
run: |
curl -sf -X POST http://localhost:8080/__admin/mappings \
-H "Content-Type: application/json" \
-d @./wiremock/scenarios/payment-timeout.json
- name: Run integration test suite
run: npm run test:integration
env:
MOCK_API_BASE_URL: http://localhost:8080
- name: Export request journal
if: always()
run: |
mkdir -p artifacts
curl -sf http://localhost:8080/__admin/requests \
-o "artifacts/mock-requests-${{ github.run_id }}.json"
- name: Upload mock artefacts
if: always()
uses: actions/upload-artifact@v4
with:
name: mock-request-journal-${{ github.run_id }}
path: artifacts/
retention-days: 14
Hot-reload vs immutable definitions: hot-reloading stub files during a test run is valuable locally but creates an eventual-consistency window in CI. If the stub directory is mutated while a test is executing, requests that overlap the reload boundary may receive stale or partially-updated responses. For pipeline stability, mount stub directories read-only and reload only on explicit container restart. Reserve hot-reload (--watch-mappings) for interactive local debugging sessions.
Scenario state management: WireMock’s built-in scenario state machine (and equivalent constructs in other mock servers) allows multi-step flows — such as a cart that progresses from empty to item-added to checked-out — to be expressed as named state transitions. Seed the initial state via the admin API before tests begin, and reset it to Started between test cases using POST /__admin/scenarios/reset.
Verification Steps
Run these commands in sequence to confirm the lifecycle is correctly wired end-to-end:
-
Start the mock container and confirm it reaches
healthystatus:docker compose -f docker-compose.mock.yml up -d docker compose -f docker-compose.mock.yml ps # Expected: mock-gateway running (healthy) -
Verify the admin health endpoint responds:
curl -sf http://localhost:8080/__admin/health # Expected: {"status":"healthy","version":"3.13.2"} -
Seed a stub and confirm it routes correctly:
curl -sf -X POST http://localhost:8080/__admin/mappings \ -H "Content-Type: application/json" \ -d '{"request":{"method":"GET","url":"/api/ping"},"response":{"status":200,"body":"{\"pong\":true}"}}' curl -sf http://localhost:8080/api/ping # Expected: {"pong":true} -
Confirm the request journal captures the probe:
curl -sf http://localhost:8080/__admin/requests | jq '.requests | length' # Expected: 1 (the /api/ping call above) -
Tear down cleanly and confirm no orphaned containers remain:
docker compose -f docker-compose.mock.yml down --remove-orphans docker ps -a | grep mock-gateway # Expected: no output
Troubleshooting
Error: EADDRINUSE :::8080 on container startup
The host port is already bound — either a previous container was not torn down or another process is using the port.
# Find what is holding port 8080
lsof -i :8080
# Force-remove orphaned compose containers before next run
docker compose -f docker-compose.mock.yml down --remove-orphans
# Or switch to dynamic port allocation in compose:
# ports:
# - "0:8080" # Docker assigns a random free host port
In CI, add docker compose down --remove-orphans to a pre-test or before_script hook to prevent accumulation across retried jobs.
curl: (22) The requested URL returned error: 503 during startup probe
The health-check probe fired before the JVM (or Node process) finished initialising. Increase start_period to 15s and verify the mock image tag has not changed:
healthcheck:
start_period: 15s # was 10s — extend for slower runners
retries: 8 # increase retry budget
Contract drift warning — Schema version mismatch: expected v2, got v1
The stub file in the mount was authored against a different OpenAPI version than the one the application under test expects. Pin mock image tags to the same Git commit that produced the OpenAPI spec:
# Generate a lockfile entry from the current spec hash
SPEC_HASH=$(sha256sum openapi/v2.yaml | cut -d' ' -f1)
echo "MOCK_IMAGE_TAG=${SPEC_HASH:0:12}" >> .env.ci
Then reference ${MOCK_IMAGE_TAG} in your compose file to guarantee the mock definition and the spec are always from the same source commit.
Dangling sockets after test run — Connection refused on next startup
The mock process received SIGKILL (e.g., a CI runner timeout) without draining active connections. Configure the runtime to trap SIGTERM and drain before exit:
# Graceful shutdown — drain active connections then exit 0
docker compose -f docker-compose.mock.yml stop --timeout 10
For WireMock, the --async-response-enabled flag prevents blocked request threads from holding the socket open during shutdown.
Intermittent 503 or timeout under parallel test execution
Multiple test workers sharing a single mock container can exhaust the connection pool. Options in increasing isolation order:
- Raise
--jetty-acceptor-threadson the WireMock process - Shard test workers across separate mock container instances with distinct port assignments
- Switch the request interception layer to in-process MSW Node handlers for the affected test suite
When to Advance
Your mock lifecycle setup is correctly implemented when all of the following are true:
-
docker compose psconsistently shows(healthy)for the mock container within 30 seconds on a cold pull - The CI workflow’s integration test job exits
0on three consecutive pushes without flakiness - The exported request journal (
/__admin/requests) captures every API call the test suite makes — no unexplained gaps - Tearing down with
--remove-orphansleaves zero containers, networks, or volumes with the project prefix - Stub files on disk are unchanged after a test run (confirming read-only mounts are enforced)
At this point you can extend the setup to cover running WireMock in Docker Compose with multi-service networks, or shift focus to the response shaping techniques that control what the mock returns once routing is stable.
FAQ
Should I use hot-reload in CI pipelines?
No. Hot-reload (--watch-mappings in WireMock) is designed for interactive local development — it detects file changes and applies them without a container restart. In CI, stub directories should be mounted read-only and treated as immutable for the duration of a run. A mid-run reload creates an eventual-consistency window where requests that overlap the reload boundary may hit stale or partial stub definitions. For CI, mount once at startup and restart the container if stubs need to change.
How do I prevent port collisions across parallel CI jobs?
Use Docker’s dynamic port allocation by publishing "0:8080" instead of a fixed host port. Docker assigns a free host port at container start, which you can read with docker compose port mock-gateway 8080. Combine this with per-job isolated bridge networks (named with the run ID or matrix value) so containers from concurrent jobs cannot communicate or conflict.
When should mock state persist between test runs?
Only when the test scenario specifically requires cross-session continuity — for example, a multi-step user journey where a background job runs in one session and its effects are asserted in a second. For all other cases, start each test run from a clean state. Persistent state is the most common cause of test-order dependencies, where a test passes or fails depending on which other tests ran before it.
What is the right health-check strategy for a WireMock container?
Poll /__admin/health at a 5-second interval with at least 6 retries and a start_period of 10 seconds. The start_period prevents the retry budget from being consumed before the JVM has finished loading — without it, a slow runner will exhaust retries before WireMock is capable of responding. On GitHub Actions hosted runners, the JVM cold-start time is typically 4–7 seconds; adjust start_period upward if you observe consistent health-check failures on the first 2–3 retries.
Related
- Managing Mock Server Lifecycles in Docker — containerisation patterns, multi-service networks, and volume strategies for ephemeral mock environments
- Proxy vs Inline Mocking Strategies — how your choice of interception layer determines lifecycle coupling and teardown complexity
- Request Interception Patterns — the middleware layer that fires lifecycle hooks and captures traffic for contract validation
- Response Shaping Techniques — dynamic payload generation, latency simulation, and error injection during the active routing phase
- Dockerized Mock Environments — tool-specific setup for running WireMock and Prism as Docker Compose services
← Back to API Mocking Fundamentals & Architecture