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@v4or thenode:20image) - 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 /healthendpoint on the mock that returns200once it is ready -
curlandjqon the runner for verification steps - Environment variables agreed up front:
MOCK_SEED(data determinism),MOCK_PORT, andMOCK_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.
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.
Related
- Running MSW in GitHub Actions — a complete workflow for a server-side MSW mock on a Node runner
- Caching Generated Mock Fixtures in CI — keying actions/cache on the spec hash to skip regeneration
- Ephemeral Preview Environments — promoting the CI mock stack into a per-PR reviewable URL
- Dockerized Mock Environments — the container images and Compose files these jobs reuse
- Mock Lifecycle Management — start, health-check, and teardown patterns for any mock infrastructure
← Back to CI/CD & Test Integration