MSW vs WireMock for CI Pipelines

You have narrowed the field to two credible mock servers and now face the concrete pipeline question: which one runs green, fast, and reliably inside CI? This page answers it dimension by dimension and gives a recommendation for each common pipeline shape, rather than declaring a single winner.

Context: why this specific pairing

MSW and WireMock keep landing in the same shortlist because they are both excellent yet intercept at opposite layers. As the broader mocking tool selection workflow shows, MSW installs inside the JavaScript runtime — the Service Worker catches browser calls and setupServer catches Node calls — while WireMock is a standalone HTTP server that any client on the network can reach. In CI, that layer difference decides almost everything: cold-start cost, which runtimes are covered, and how the mock is wired into the job. The proxy vs inline mocking strategies split is the same distinction viewed architecturally.

The trap is choosing on features when CI cares about mechanics. A stateful scenario engine is worthless if the tests are a JavaScript unit suite that only needs handlers; sub-second startup is worthless if a Go service in the same job cannot see the mock at all.

Head-to-head across the dimensions that decide CI

Dimension MSW WireMock
Startup time Near-zero — installs in-process in the test runner Seconds — JVM boot; amortised if shared across a job
Runtime / language coverage JavaScript only (browser + Node) Any client over HTTP (JVM, Go, Python, mobile, CLI)
Protocol coverage REST, GraphQL, WebSocket (via handlers) REST, plus gRPC and low-level HTTP faults
Statefulness Manual state inside handlers Native scenarios with named states
Resource cost in CI Shares the test process — no extra container Its own container/JVM; modest memory footprint
Container ergonomics Bundled into the Node test image First-class official image, --wait-friendly healthcheck
Failure visibility Unhandled request surfaces in test output Request journal at /__admin/requests

Read down the columns and a pattern emerges. MSW wins every row that is about speed and colocation with JavaScript tests; WireMock wins every row that is about reach beyond JavaScript and stateful, protocol-level realism. Neither is strictly better — they optimise for different pipeline shapes.

Startup time

MSW has effectively no startup: server.listen() runs synchronously in the same process as your tests, so there is nothing to boot and nothing to wait for. WireMock must start a JVM and bind a port, which costs a few seconds. That cost is trivial if you start WireMock once per job and share it, and painful if you start a fresh instance per test shard.

Runtime and language coverage

This is the dimension that overrides all others. MSW physically cannot intercept a request from a non-JavaScript runtime — a Go service or a mobile build in the same job will sail straight past it to the real network. WireMock listens on a port and serves anything that can make an HTTP call, which is why polyglot pipelines lean on WireMock standalone configuration.

Statefulness

If a test does POST /orders then GET /orders/:id and expects the created record back, WireMock’s scenarios model that natively with named state transitions. MSW can do it too, but you manage the state yourself inside the handler closure, which is fine for a couple of steps and brittle across long sequences.

Minimal CI snippet for each

MSW — in-process, nothing to boot

MSW needs no service container. You install it in the test setup and the interception happens inside the runner:

// vitest.setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

export const server = setupServer(
  http.get('https://api.example.com/orders', () =>
    HttpResponse.json([{ id: 'ord_1', status: 'paid' }])
  )
);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
# .github/workflows/test.yml — MSW path
name: Unit tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      # No service container — MSW installs inside Vitest
      - run: npx vitest run

The onUnhandledRequest: 'error' flag is the load-bearing line: it fails the build loudly if any request escapes the mock, which is exactly the guarantee you want in CI.

WireMock — a shared, health-gated service container

WireMock runs as a container that every job step (and every non-JS process) can reach. Start it once and gate the tests on its health:

# .github/workflows/test.yml — WireMock path
name: Integration tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      wiremock:
        image: wiremock/wiremock:3.5.4
        ports:
          - 8080:8080
        options: >-
          --health-cmd "wget --spider -q http://localhost:8080/__admin/health || exit 1"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - name: Load mappings into WireMock
        run: |
          for f in wiremock/mappings/*.json; do
            curl -sf -X POST http://localhost:8080/__admin/mappings \
              -H "Content-Type: application/json" --data @"$f"
          done
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - name: Run integration suite against WireMock
        run: TEST_API_BASE_URL=http://localhost:8080 npx vitest run

Because the service block boots WireMock before any step runs and the --health-* options gate readiness, the JVM startup is paid once and every subsequent step — including non-JS processes you might add — sees a warm server. This is the same lifecycle pattern used for running WireMock in Docker Compose, just expressed as a GitHub Actions service.

For the JavaScript-only pipeline, the equivalent hardening and caching details live in the guide to running MSW in GitHub Actions.

Verification / decision checklist

Work top to bottom; the first decisive answer picks the tool.

  • Is every runtime under test JavaScript? If no → WireMock (MSW cannot see non-JS traffic). If yes, continue.
  • Do flows need stateful sequences across requests? If yes and they are complex → WireMock scenarios. If no or trivial, continue.
  • Is per-shard startup time a bottleneck in a large JS suite? If yes → MSW (in-process, zero boot).
  • Do you need gRPC or low-level HTTP fault injection? If yes → WireMock.
  • Default for a pure JS unit/component suite: MSW.
  • Default for cross-language or end-to-end integration: WireMock, started once as a shared service.

Recommendation by scenario:

  • JS-only unit and component tests, hundreds of specs: MSW. In-process interception keeps every shard fast and needs no container.
  • A React frontend plus a Go or JVM backend hitting the same API in one job: WireMock. Only a network-layer server covers both runtimes.
  • Contract-heavy suite with POST-then-GET sequences: WireMock scenarios, unless the sequences are short enough to model in MSW handlers.
  • Monorepo with both: run MSW in the JS shards and one shared WireMock service for the integration job — see the FAQ.

Gotchas and edge cases

  • Starting WireMock per shard silently multiplies boot cost. If a matrix runs ten shards and each starts its own WireMock, you pay JVM boot ten times. Start it once as a job-level service (as above) and share the port, or the startup dimension flips from trivial to dominant.
  • MSW’s onUnhandledRequest defaults to a warning, not an error. In CI you want 'error'. Left at the default, a request that no handler matches leaks to the real network and the suite can pass while silently hitting a live API — the worst kind of green build.
  • WireMock mapping load races the test start. Loading mappings via /__admin/mappings must complete before the first request. Gate on /__admin/health and confirm the mapping count with curl -s http://localhost:8080/__admin/mappings | jq '.mappings | length' before running tests, or the first few specs hit an empty stub set.

FAQ

Is it ever right to run both MSW and WireMock in the same pipeline?

Yes. Run MSW in the fast JavaScript unit and component shards where in-process interception costs nothing, and start WireMock once as a shared service container for the cross-language or end-to-end jobs that a JS-only interceptor cannot cover. Keep the response definitions generated from one source so the two never diverge — for instance, derive the WireMock mappings from the same fixtures your MSW handlers import.

Does WireMock’s JVM startup really matter in CI?

It matters most when you start a fresh WireMock per test shard. A single warm WireMock service container shared across a job amortises the few seconds of JVM boot to near-zero per test. The cost becomes significant only when you spin up and tear down the container repeatedly, which is an anti-pattern worth avoiding — start it once at the job level and gate readiness on /__admin/health.


← Back to Mocking Tool Selection