Running MSW in GitHub Actions

You run setupServer from msw/node and your tests pass locally, but the same suite in GitHub Actions either connects to the real API or fails with a connection error before a single assertion runs. The mock that felt automatic on your laptop needs to be started, exposed, and waited on explicitly once it lives on a cold Ubuntu runner.

Why this fails on a Node runner

MSW’s server-side mode intercepts fetch and http calls inside the Node process that called server.listen(). That is exactly what you want when the code under test runs in the same process — a Vitest or Jest suite, for example, where the setup file starts the server before the tests import the module that makes requests.

The trouble starts when the consumer is a different process: an end-to-end runner that launches a browser, a built app served by vite preview, or a sibling service. That process never called server.listen(), so MSW’s interception is invisible to it, and its requests sail straight to the network. On a developer machine this often works by accident because a dev server is already proxying somewhere convenient; on a fresh runner there is nothing to catch the call.

The fix is to stop treating MSW as an in-process detail and start treating it as a real, addressable mock server: wrap the handlers in an HTTP listener, start it as a background job step, and gate the tests on its health — the general shape described in Running Mock Servers in CI Pipelines. The handlers themselves are unchanged from your MSW setup.

The job, step by step Five steps. Checkout, a Node setup with a dependency cache, a clean install, the test run with unhandled requests configured to error, and artefact upload on failure. No service container appears anywhere, because MSW installs inside the runner process. No service block, no health gate, no port — the whole point of an in-process interceptor. checkout the branch under test nothing special setup-node + cache keyed on the lockfile the largest time saving npm ci reproducible install never npm install vitest run unhandled requests error the load-bearing flag artefacts on failure logs and coverage only when it fails The cache step is worth more than every other optimisation here combined on a cold runner.

Solution

1. Expose setupServer over HTTP with a health route

Give the mock a front door and a readiness signal:

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

const PORT = Number(process.env.MOCK_PORT ?? 8080);

// Intercept outbound requests made from THIS process...
const msw = setupServer(...handlers);
msw.listen({ onUnhandledRequest: 'error' });

// ...and also answer inbound requests from OTHER processes (E2E, curl).
const http = createServer(async (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;
  }
  // Delegate every other path to the MSW handlers via a fetch round-trip.
  const url = `http://localhost:${PORT}${req.url}`;
  const body = ['GET', 'HEAD'].includes(req.method ?? 'GET')
    ? undefined
    : await new Promise<string>((resolve) => {
        let data = '';
        req.on('data', (c) => (data += c));
        req.on('end', () => resolve(data));
      });
  const proxied = await fetch(url, {
    method: req.method,
    headers: req.headers as Record<string, string>,
    body,
  });
  res.writeHead(proxied.status, {
    'content-type': proxied.headers.get('content-type') ?? 'application/json',
  });
  res.end(await proxied.text());
});

http.listen(PORT, () => console.log(`MSW mock listening on :${PORT}`));

process.on('SIGTERM', () => {
  msw.close();
  http.close(() => process.exit(0));
});

Add the start script and a wait helper:

{
  "scripts": {
    "mock:start": "tsx mocks/server-entry.ts",
    "test:integration": "vitest run"
  }
}
#!/usr/bin/env bash
# scripts/wait-for-mock.sh
set -euo pipefail
PORT="${MOCK_PORT:-8080}"
for i in $(seq 1 30); do
  if curl -fsS "http://localhost:${PORT}/health" > /dev/null 2>&1; then
    echo "Mock healthy after ${i}s"; exit 0
  fi
  sleep 1
done
echo "Mock did not become healthy in 30s" >&2
exit 1

2. Add the workflow

This is a complete, copy-paste workflow — no placeholders:

# .github/workflows/msw-integration.yml
name: msw-integration
on: [push, pull_request]

jobs:
  integration:
    runs-on: ubuntu-latest
    env:
      MOCK_PORT: "8080"
      MOCK_SEED: "42"
      MOCK_BASE_URL: "http://localhost:8080"
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Start MSW mock in the background
        run: |
          npm run mock:start > mock.log 2>&1 &
          echo $! > mock.pid

      - name: Wait for the mock to be healthy
        run: bash scripts/wait-for-mock.sh

      - name: Run integration tests
        run: npm run test:integration

      - name: Print mock log on failure
        if: failure()
        run: cat mock.log

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

3. Point the suite at the mock

The application under test must read its base URL from the environment, not a hard-coded host, so the same code targets the mock in CI and the real API elsewhere:

// src/lib/config.ts
export const API_BASE_URL =
  process.env.MOCK_BASE_URL ?? process.env.API_BASE_URL ?? 'https://api.acme.com';

Centralising the base URL in one module is the network layer abstraction that keeps environment differences out of the rest of the codebase.

The flags that make a green build meaningful Four settings. Unhandled requests must error rather than warn. Handlers must reset between tests. Snapshots must not be written in CI. And the runner must fail on an unhandled rejection. Each is paired with the false-green it prevents. Setting Value in CI False-green it prevents onUnhandledRequest error a request quietly reaching a real API resetHandlers in afterEach one spec's override passing the next snapshot writing disabled with --ci a new snapshot created and asserted in one run unhandled rejections fail the run an async error swallowed into a pass Every row turns a build that passes for the wrong reason into one that fails for the right one.

Verification

One command proves the mock is up and serving inside the job:

curl -fsS "${MOCK_BASE_URL}/health" | jq -e '.status == "ok"'

jq -e exits non-zero if the assertion fails, so this line doubles as a gate — a green exit means the mock answered with {"status":"ok"} and the suite is safe to run.

Gotchas and edge cases

  • onUnhandledRequest: 'error' is non-negotiable in CI. With 'warn', an unstubbed call quietly hits the real network and your test passes against production data. Set it to 'error' so a missing handler fails the job loudly. Add the handler using advanced MSW handler patterns.
  • The background process must be killed under if: always(). GitHub Actions does not reap detached processes for you between jobs on self-hosted runners; a leaked tsx process holds port 8080 for the next run. Writing the PID to a file and killing it in a final always-step prevents EADDRINUSE on the following build.
  • tsx must be a dependency, not assumed global. The runner has no global TypeScript loader. Add tsx to devDependencies so npm ci installs it; otherwise npm run mock:start fails with command not found before the health loop even begins.

Where the minutes go Four contributors to job duration: dependency installation, the test run itself, browser download when a browser suite is present, and artefact upload. Because MSW adds no container and no boot, the mock layer contributes essentially nothing — which is the reason to prefer it for JavaScript-only suites. Dependency install dominates a cold cache cache keyed on the lockfile The test run scales with spec count shard across a matrix Browser download only for browser suites cache the browser binaries too The mock layer effectively zero no container, no boot, no gate The last row is the argument: in a JavaScript-only pipeline the mock should not appear in the timing breakdown at all.

Keeping the job honest as the suite grows

A GitHub Actions job that runs MSW starts simple and accumulates ways to be quietly wrong. Four habits keep it trustworthy.

Fail on anything unhandled, and check that the failure is visible. onUnhandledRequest: 'error' throws inside the request, which some test setups swallow into a generic assertion failure. Grep the job log for MSW’s own warning text as a post-step; if that string ever appears in a passing build, the error is being caught somewhere and the guarantee is gone.

Pin the runner image where reproducibility matters. ubuntu-latest moves. A job that passed last week and fails today with no code change is often a runner image bump — a different Node patch, a different OpenSSL. Pinning to a dated label makes those changes deliberate and dateable rather than mysterious.

Cache on the lockfile, and only the lockfile. A cache key that includes a branch name restores whatever that branch last left behind, which for a job that generates fixtures means inheriting the previous run’s data. Keyed on the lockfile hash, the cache is a pure function of its inputs and can never carry state forward.

Shard before the suite is slow, not after. A matrix added at ten minutes is a configuration change; added at forty minutes it is a rewrite, because by then specs have grown dependencies on execution order that only surface under sharding. Running --sequence.shuffle occasionally from the start keeps that from happening.

Upload something on failure. A job that fails with only an assertion message produces a rerun. The minimum useful artefact set is the test reporter output, the resolved environment (with secrets redacted), and any coverage or trace files. None of them are large, and together they usually remove the need to reproduce locally at all.

Each of these is a few lines of YAML and each removes a category of failure that would otherwise be diagnosed as flake.

Keeping the workflow readable

Workflow files accumulate steps faster than anything else in a repository, and a mocked test job is a common place for that to happen.

The habit worth keeping is that every step should be explicable in one sentence. Steps that exist because of a problem nobody remembers — a sleep, a retry, a cache with an odd key — are the ones that make a workflow unmaintainable, and they are almost always the residue of a race that has since been fixed properly elsewhere.

← Back to Running Mock Servers in CI Pipelines