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.
Sharding, matrices and the mock
The moment a suite is split across parallel jobs, the mock stops being a detail of the test setup and becomes part of the pipeline’s topology. Three questions decide how it behaves.
One instance or many? A single shared mock server is cheaper — one startup, one container — and it makes every job’s traffic visible in one journal, which is convenient right up until a count assertion sees another job’s requests. One instance per job costs a startup each and buys complete isolation. The deciding factor is usually whether any assertion counts requests: if none do, sharing is fine; if any do, sharing is a source of failures that only appear under load.
Where does the startup cost land? A container started at the job level pays boot once, however many steps run afterwards. A container started per step pays it every time. Under a ten-way matrix that difference is ten boots against one hundred, and it is entirely invisible in a single-job proof of concept.
How is state partitioned? Even with one instance per job, a job that runs several test files in parallel inside itself needs partitioning below the job level. A scope header carried on every request, with the mock keying its store on that header, is the mechanism that scales to any level of parallelism without adding containers.
There is a fourth question that is easy to miss: what happens to the fixtures? If each shard generates its own, the generation cost is multiplied by the shard count and the fixtures are identical every time — which is exactly what a cache is for. Generating once in a preparatory job and passing the output to every shard turns a linear cost into a constant one, and it also guarantees that every shard is testing against byte-identical data.
The pattern that scales, then, is: generate fixtures once and cache them; start one mock per job at job level; partition state within the job by a scope header; and assert on counts only within a scope. Each piece is small, and together they are the difference between a matrix that grows linearly and one that starts failing intermittently somewhere past four workers.
What to measure once it works
A mock stack in CI is easy to leave alone once it stops failing, and a few cheap measurements catch degradation before it becomes a rewrite.
Time from job start to first assertion. This is the startup overhead, and it grows quietly — an extra service, a longer health gate, a bigger fixture set. Watching the number makes the growth visible while it is still one change to reverse.
The proportion of runs that are reruns. A rising rerun rate is the clearest signal that determinism has slipped, usually well before anyone files it as a problem.
Unmatched requests per run. It should be zero. Anything else means an endpoint is falling through, and the count rising from zero is far easier to notice than a single unmatched request buried in a passing log.
Fixture generation time. If it is climbing, either the dataset is growing without anyone deciding to grow it, or the generator has picked up work it should not be doing.
None of these require tooling beyond what the pipeline already produces. A short step that extracts the four numbers and prints them makes the trend visible in any log, and that visibility is usually enough — problems in this area are almost always noticed late rather than misdiagnosed.
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