Running Mock Servers in CI Pipelines

This page covers one job: getting a mock server up, healthy, and serving inside a continuous-integration run — first on GitHub Actions, then on GitLab CI — so that automated tests hit a controlled mock instead of a real or flaky upstream.

Prerequisites

  • Node.js 20 or later on the runner (actions/setup-node@v4 or the node:20 image)
  • Docker with the Compose plugin available on the runner (only for the containerised path)
  • A mock server you can start headless — either an MSW setup exposing setupServer, or a WireMock standalone configuration with a mappings directory
  • A GET /health endpoint on the mock that returns 200 once it is ready
  • curl and jq on the runner for verification steps
  • Environment variables agreed up front: MOCK_SEED (data determinism), MOCK_PORT, and MOCK_BASE_URL

Job timeline

Every recipe on this page follows the same five-beat timeline: check out the code, start the mock, wait until it is healthy, run the (possibly sharded) tests, and tear the mock down no matter what happened. Holding that shape in mind keeps the YAML readable.

CI job timeline: checkout, start mock, wait for health, shard tests, teardown A left-to-right timeline of a CI job with five stages: checkout, start mock, wait for health, shard tests running in parallel, and teardown that always runs. checkout start mock wait /health shard tests 1/3 shard tests 2/3 shard tests 3/3 teardown if: always()

Phase 1 — Core setup

There are two ways to run the mock. Pick the in-process path for a JavaScript-only suite and the container path when anything outside the Node process needs to reach the mock.

Option A — a background MSW Node server

For a Node suite, setupServer runs in-process and needs no separate service. But when an out-of-process consumer (a Playwright browser, a sibling API) must reach it, wrap the handlers in a tiny HTTP server and start it in the background:

// mocks/standalone-server.ts
import { createServer } from 'node:http';
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

const PORT = Number(process.env.MOCK_PORT ?? 8080);
const server = setupServer(...handlers);
server.listen({ onUnhandledRequest: 'warn' });

// Minimal health endpoint so CI can gate on readiness.
const health = createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ status: 'ok', seed: process.env.MOCK_SEED ?? '42' }));
    return;
  }
  res.writeHead(404).end();
});

health.listen(PORT, () => {
  console.log(`Mock health endpoint on :${PORT}`);
});

Start it detached and record its PID so teardown can stop it:

node --loader tsx mocks/standalone-server.ts &
echo $! > /tmp/mock.pid

Option B — a docker compose mock service

When the mock must be language-agnostic, run it as a Compose service. This reuses the WireMock image and mappings from your dockerized mock environment:

# docker-compose.ci.yml
services:
  wiremock:
    image: wiremock/wiremock:3.9.1
    command: ["--port", "8080", "--disable-banner"]
    volumes:
      - "./wiremock/mappings:/home/wiremock/mappings:ro"
      - "./wiremock/__files:/home/wiremock/__files:ro"
    ports:
      - "8080:8080"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/__admin/health"]
      interval: 5s
      timeout: 3s
      retries: 5

Phase 2 — Configuration and wiring

Environment flags

Keep every knob at the environment boundary so the same image or script behaves predictably in every job:

# Shared by both options
export MOCK_PORT=8080
export MOCK_SEED=42
export MOCK_BASE_URL="http://localhost:${MOCK_PORT}"

MOCK_SEED flows into your deterministic seed management so the dataset is identical run to run; MOCK_BASE_URL is what the application-under-test reads through its network layer abstraction.

GitHub Actions service container

The cleanest way to declare a network-reachable mock on GitHub Actions is a service container. The runner starts it, applies the health command, and only proceeds when it reports healthy:

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      wiremock:
        image: wiremock/wiremock:3.9.1
        ports:
          - 8080:8080
        options: >-
          --health-cmd "curl -fsS http://localhost:8080/__admin/health"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5
    env:
      MOCK_BASE_URL: http://localhost:8080
    steps:
      - uses: actions/checkout@v4
      - name: Load WireMock mappings
        run: |
          for f in wiremock/mappings/*.json; do
            curl -fsS -X POST --data @"$f" http://localhost:8080/__admin/mappings
          done
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm run test:integration

Because the service container image ships empty, the mappings are pushed in through the admin API after the runner reports the container healthy — a wiring step that mirrors the file-mounted approach in running WireMock in Docker Compose.


Phase 3 — Integration: parallelism, matrix, and caching

Once one job works, scale it. Split the suite across a matrix so wall-clock time falls with runner count, and cache the fixtures so cold jobs skip regeneration.

# .github/workflows/ci-matrix.yml
name: ci-matrix
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci

      - name: Restore fixture cache
        id: fixtures
        uses: actions/cache@v4
        with:
          path: mocks/generated
          key: fixtures-${{ hashFiles('specs/api.yaml') }}

      - name: Regenerate on cache miss
        if: steps.fixtures.outputs.cache-hit != 'true'
        run: npm run generate:fixtures

      - name: Start mock
        run: |
          npm run mock:start &
          bash scripts/wait-for-mock.sh

      - name: Run shard ${{ matrix.shard }}
        run: npx vitest run --shard=${{ matrix.shard }}/3
        env:
          MOCK_BASE_URL: http://localhost:8080

      - name: Stop mock
        if: always()
        run: kill "$(cat /tmp/mock.pid)" || true

The full generate-vs-restore logic, including restore-keys fallbacks, is the subject of Caching Generated Mock Fixtures in CI. A single-shard, MSW-specific walkthrough lives in Running MSW in GitHub Actions.


Verification steps

Run these on the runner (or locally with the same env) to confirm the mock is serving before the suite starts.

  • Health endpoint answers:
    curl -fsS "${MOCK_BASE_URL}/health" | jq .
    # Expected: { "status": "ok", "seed": "42" }
  • A known mapping returns its stub:
    curl -fsS "${MOCK_BASE_URL}/api/v1/users" | jq '.data | length'
    # Expected: an integer > 0
  • WireMock admin reports loaded mappings (container path):
    curl -fsS "${MOCK_BASE_URL}/__admin/mappings" | jq '.mappings | length'
    # Expected: matches the number of files you loaded
  • The port is bound by exactly one process:
    ss -ltnp | grep ":${MOCK_PORT}" | wc -l
    # Expected: 1

Troubleshooting

Error: listen EADDRINUSE: address already in use :::8080

A previous step, a leaked container, or a runner-resident service already holds the port. Free it or move the mock. In a matrix, give each shard a distinct port derived from its index:

export MOCK_PORT=$((8080 + MATRIX_SHARD))
export MOCK_BASE_URL="http://localhost:${MOCK_PORT}"

Confirm nothing else is bound with ss -ltnp | grep :8080 before starting.

curl: (7) Failed to connect to localhost port 8080: Connection refused

The test step raced the mock’s startup — the container exists but is not yet listening. Never paper over this with sleep 5. Insert the health loop and let it block:

until curl -fsS "${MOCK_BASE_URL}/health" > /dev/null 2>&1; do sleep 1; done

If it never connects, the mock crashed on boot — inspect docker compose logs wiremock or the background Node process output.

[MSW] Warning: intercepted a request without a matching request handler

An onUnhandledRequest: 'warn' server let an unstubbed call slip through and hit the real network. In CI, promote this to a hard failure so the test breaks instead of silently making a live call:

server.listen({ onUnhandledRequest: 'error' });

Add the missing handler using the patterns in advanced MSW handler patterns.

The mock container exits immediately with code 0 or 1

The container ran to completion instead of staying up, usually because the command was one-shot or a mapping file is malformed. Check the exit reason:

docker compose -f docker-compose.ci.yml logs wiremock
docker compose -f docker-compose.ci.yml ps -a

For WireMock, a JSON syntax error in a mappings file aborts startup — validate every file with jq empty wiremock/mappings/*.json before the run.

Tests pass individually but a sharded run is flaky

Two shards are mutating one shared mock. Reset scenario and mapping state at the start of each shard, or run a dedicated instance per shard. The full treatment is in Resetting Mock State Between Test Runs.


When to advance

You are ready to move on to disposable per-PR environments when:

  • The mock starts and reports healthy on every push, with no sleep-based waits left in the workflow.
  • A sharded matrix run is green and reproducible — re-running it yields the same result.
  • Fixtures are restored from cache on unchanged specs and regenerated only when the contract moves.
  • Teardown runs under if: always() and leaves no bound ports or orphaned containers behind.

At that point, promoting the same stack into a live, reviewable URL per pull request is the natural next step — see Ephemeral Preview Environments.


FAQ

Do I need Docker to run a mock in CI, or can I run MSW directly?

You do not need Docker for a JavaScript-only suite. MSW’s setupServer runs in the same Node process as the tests, so there is no separate service to start, no port to gate on, and no container to tear down. Reach for Docker when a browser E2E tool or a non-JavaScript service must connect to the mock over the network — at that point a WireMock or Node container gives every consumer one shared, addressable endpoint. The MSW vs WireMock for CI Pipelines comparison walks through the trade-off in detail.

How do I share one mock instance across parallel shards without state bleed?

Either give each shard its own instance on a distinct port, or reset the shared instance between shards. For WireMock, POST to /__admin/scenarios/reset and /__admin/mappings/reset at the start of each shard so no shard inherits another’s runtime mappings. For MSW you rarely share an instance at all, because each worker runs its own in-process setupServer, which sidesteps the problem. When isolation is cheaper than coordination, prefer a dedicated instance per shard.

Where should generated fixtures live — committed or cached?

Cache them. Committing generated fixtures invites drift because a reviewer cannot tell a hand-edit from a regeneration, and the checked-in copy silently rots as the spec evolves. Instead, generate on a cache miss keyed on the OpenAPI spec hash and restore on a hit, so the fixtures are always derivable from the current contract via schema-driven data generation and never reviewed by hand. The mechanics live in Caching Generated Mock Fixtures in CI.


← Back to CI/CD & Test Integration