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.

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.

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.

← Back to Running Mock Servers in CI Pipelines