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 leakedtsxprocess holds port 8080 for the next run. Writing the PID to a file and killing it in a final always-step preventsEADDRINUSEon the following build. tsxmust be a dependency, not assumed global. The runner has no global TypeScript loader. AddtsxtodevDependenciessonpm ciinstalls it; otherwisenpm run mock:startfails withcommand not foundbefore the health loop even begins.
Related
- Caching Generated Mock Fixtures in CI — skip regenerating the data this mock serves on every run
- Running Mock Servers in CI Pipelines — the parent guide covering GitLab, service containers, and sharding
- Mock Service Worker (MSW) Setup — authoring the handlers this workflow starts
← Back to Running Mock Servers in CI Pipelines