supertest, nock, and when the mock needs to be a real URLHonest split first: supertest + nock is the right default for unit-testing Express routes that call external APIs. supertest drives your app over real HTTP without you managing a port, and nock 14 intercepts both axios-style clients and Node 22's native fetch โ we verified that below, because it used to be the thing that silently broke. This page is not going to pretend otherwise.
One scoping note before anything else: supertest tests your app's inbound HTTP. It mocks nothing outbound. When your route calls fetch(upstreamUrl), that call is as real under supertest as in production โ we measured it, live data and all. That confusion sends a lot of people searching, so it gets its own verified section.
Everything below was verified the week of writing (Aug 2026: Express 5.2, supertest 7.2, nock 14.0, Node 22, Vitest 4) โ every snippet on this page was actually run, in a 10-test suite that passes.
The app under test proxies an upstream. We hit it through supertest with no interceptor registered:
// route: fetch(`${UPSTREAM}/products?sortBy=price&order=desc&_limit=3`)
const res = await request(app).get("/api/top-products");
res.body.top[0]
// โ { name: "Name Water Book House", price: 987.16 }
// real live upstream data, fetched over the real network, mid-test (verified)
supertest spins your app up on an ephemeral port and sends requests into it. Everything your handlers do โ outbound fetches included โ runs for real. supertest + no interception library = your test suite is hammering the actual third-party API (or failing in CI where there's no network). You need both: supertest to drive your app, and something to answer your app's outbound calls.
disableNetConnect() breaks supertest itselfCredit where due: nock 14 hooks Node's global fetch, so the same stub covers axios, got and fetch() in your handlers:
nock("https://mockbird.mockbird.workers.dev")
.get("/m/demo/products").query(true)
.reply(200, [{ name: "Stubbed Widget", price: 1 }]);
const res = await request(app).get("/api/top-products");
res.body.top // [{ name: "Stubbed Widget", price: 1 }] โ intercepted โ (verified)
The classic footgun is the safety switch. nock.disableNetConnect() is the right instinct โ fail loudly on any unmocked call โ but supertest's own connection to your app is a real socket to 127.0.0.1, so it gets blocked too and every test dies with Disallowed net connect. The fix (verified):
nock.disableNetConnect();
nock.enableNetConnect(host => host.includes("127.0.0.1") || host.includes("localhost"));
// supertest works again; unmocked *external* calls still fail loudly
We registered a persistent nock stub for the upstream URL, then spawned the same Express app as a real server (node server.js, own process) and called it:
nock(ORIGIN).get("/m/demo/products").query(true)
.reply(200, [{ name: "GHOST", price: 0 }]).persist();
spawn("node", ["server.js"]); // real server, own process
const body = await (await fetch("http://127.0.0.1:4719/api/top-products")).json();
body.top[0].name // NOT "GHOST" โ the real upstream record. (verified)
nock monkey-patches http and the fetch dispatcher in this Node process. A server started with node server.js, a PM2/cluster worker, a Docker container, a BullMQ job, your React frontend's fetch, a Playwright browser, a teammate's laptop โ none of them can be reached by it. The moment the thing that makes the call lives outside your test process, the mock has to be a real URL.
Mockbird gives you a stateful mock REST API at https://mockbird.mockbird.workers.dev/m/<project> โ one curl to create, seeded with realistic data, CORS open, no signup. Your Express app just points its upstream base URL at it (the env var you already have), and every caller โ handlers, the spawned server, workers, the browser โ sees the same mock, because it's not a monkey-patch, it's an actual API.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' \
-d '{"preset":"ecommerce"}'
# โ { "id": "abc123...", "adminKey": "...", "baseUrl": ".../m/abc123..." }
UPSTREAM=https://mockbird.mockbird.workers.dev/m/abc123 node server.js
// route's upstream-failed branch, on demand โ real HTTP 503:
await request(appWith("mock_status=503")).get("/proxy"); // โ 502 error path (verified)
// route uses fetch(url, { signal: AbortSignal.timeout(1000) })
// upstream answers with ?mock_delay=3000
const res = await request(app).get("/proxy-timeout");
// โ 504 after a measured 1013 ms โ the abort genuinely fired (verified)
?mock_seq=503,503,200 makes the endpoint answer 503, 503, then real data:
// handler: for (let attempt = 1; attempt <= 3; attempt++) { ... }
const res = await request(app)
.get("/api/reliable-products")
.query({ extra: "mock_seq=503,503,200&mock_seq_key=worker1" });
res.body.attempts // 3 โ exactly two failures, then recovery (verified)
mock_seq_key isolates parallel test workers. More recipes in testing loading & error states.
A nock stub is a canned script. A mocked POST "creates" nothing โ the next GET returns whatever you scripted, because there is no store behind it. Against Mockbird the write is real:
const rec = await (await fetch(`${API}/products`, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Express Guide Test", price: 9.99 }),
})).json(); // 201, real id
(await fetch(`${API}/products/${rec.id}`)).status // 200 โ actually there
await fetch(`${API}/products/${rec.id}`, { method: "DELETE" });
(await fetch(`${API}/products/${rec.id}`)).status // 404 (verified)
For deterministic test data across runs (and parallel workers), save a named snapshot and pin it per-request with the X-Mockbird-Snapshot header โ deterministic test data guide.
| Express testing world | Mockbird | Notes |
|---|---|---|
nock(origin).get(path).reply(...) | a resource on a real URL | list/get/create/update/delete generated, plus filtering, sorting, pagination, relations |
| Hand-rolled fixture objects | seeded realistic records | faker-style names/emails/prices/dates; or import your exact records from db.json/CSV/OpenAPI |
.reply(503) | ?mock_status=503 | on any URL, no re-registration |
.delay(3000) | ?mock_delay=3000 / ?mock_jitter | real time passes โ your AbortSignal.timeout genuinely fires (measured) |
Ordered .reply() chains for retries | ?mock_seq=503,503,200 | deterministic sequence, mock_seq_key per worker (verified recovery) |
| (no flakiness simulation) | ?mock_chaos=0.5 | real 5xx/429s for your retry logic to absorb |
| Config/env upstream URL | env-var base URL | point UPSTREAM at the mock; app code doesn't change |
scope.done() assertions | request inspector | last 50 requests with method, path, query, headers, body |
nock.enableNetConnect(host) | n/a | keep using it โ allow 127.0.0.1 for supertest and the hosted mock's origin for the rest |
curl 'https://mockbird.mockbird.workers.dev/m/demo/products?_limit=3&sortBy=price&order=desc'
curl -i 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=503'
curl 'https://mockbird.mockbird.workers.dev/m/demo/products/1?mock_delay=2000'
All against the shared demo project (resets daily).
UPSTREAM. App code doesn't change.enableNetConnect the hosted mock's host for everything else.GET /m/<project>/db.json ejects your entire dataset any time; openapi.json, postman.json and types.ts are generated from your live schema.supertest + nock | Mockbird | |
|---|---|---|
| What it is | npm libraries in your test process | free hosted service |
| Reachable from | HTTP calls in the test process only | anything with HTTP: browser, Playwright, curl, spawned servers, workers, mobile, CI, teammates |
| Node 22 native fetch | intercepted since nock 14 (verified) | it's a real URL โ every client works by definition |
| Unmatched request | loud with disableNetConnect() โ but that also blocks supertest's own socket until you allow 127.0.0.1 (verified) | n/a โ real endpoints answer real queries |
| Setup | register stubs per test | one curl or one click; no code |
| Stateful CRUD | no โ scripted answers only | default โ writes persist |
| Latency/retry/timeout testing | .delay() exists, in-process only | ?mock_delay/?mock_seq/?mock_chaos, real time passes (measured 1013 ms abort) |
| Works offline | yes | no โ it's a real network call |
| Request assertions | scope.done(), precise, in-test | request inspector (last 50, headers/body) |
| Request cap | none | 10,000/project/day |
Written by the Mockbird maker โ bias disclosed. Where supertest and nock genuinely win: they run offline at zero latency, nock 14's native-fetch support removed the old blind spot, scope.done() catches "the route never called upstream" bugs no log can, and supertest's fluent assertions are the nicest way to test Express handlers, full stop. For fast unit tests they should stay your default. When the thing you need is a URL โ for a spawned server, a queue worker, a frontend fetch, Playwright, a teammate, or CI against a deployed preview โ that's us.
Full API reference in the docs. More guides: mock API for Node.js ยท mock APIs in NestJS ยท mock APIs in Jest ยท mock APIs in Vitest ยท nock alternative ยท deterministic test data ยท testing loading & error states. Create your API โ