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.2 or later) — or an equivalent mock server with an admin HTTP API
  • A CI runner with Docker socket access (GitHub Actions ubuntu-latest satisfies 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

Mock Server Lifecycle — Four Phases A horizontal flow diagram showing the four phases of a mock server lifecycle: Initialisation (startup probe, health check), Active Routing (request interception, response shaping), State Mutation (scenario transitions, hot-reload), and Graceful Teardown (SIGTERM drain, log archival). Arrows connect each phase in sequence. Initialisation startup probe health check Active Routing interception response shaping State Mutation scenario transitions hot-reload (dev only) Graceful Teardown SIGTERM drain log archival 1 2 3 4

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.2 not wiremock: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:

  1. Start the mock container (Docker service or docker compose up -d)
  2. Block the test runner until the health probe passes
  3. Seed scenario state via the admin API
  4. Execute the test suite
  5. Export the request journal for regression artefacts
  6. 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 healthy status:

    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:

  1. Raise --jetty-acceptor-threads on the WireMock process
  2. Shard test workers across separate mock container instances with distinct port assignments
  3. 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 ps consistently shows (healthy) for the mock container within 30 seconds on a cold pull
  • The CI workflow’s integration test job exits 0 on 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-orphans leaves 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.


← Back to API Mocking Fundamentals & Architecture