โ† All guides

Mock APIs in Express โ€” supertest, nock, and when the mock needs to be a real URL

Honest 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 three boundaries that send people searching

1. supertest alone: your route fetches the REAL external API mid-test

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.

2. nock 14 does intercept native fetch โ€” but disableNetConnect() breaks supertest itself

Credit 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

3. No in-process mock survives the process boundary

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.

The hosted half: a mock API on 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

Error paths without registering a single stub

// route's upstream-failed branch, on demand โ€” real HTTP 503:
await request(appWith("mock_status=503")).get("/proxy");   // โ†’ 502 error path (verified)

Timeouts where real time passes

// 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)

Retry logic against a deterministic failure sequence

?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.

State: scripted answers vs. an actual store

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 concepts โ†’ Mockbird

Express testing worldMockbirdNotes
nock(origin).get(path).reply(...)a resource on a real URLlist/get/create/update/delete generated, plus filtering, sorting, pagination, relations
Hand-rolled fixture objectsseeded realistic recordsfaker-style names/emails/prices/dates; or import your exact records from db.json/CSV/OpenAPI
.reply(503)?mock_status=503on any URL, no re-registration
.delay(3000)?mock_delay=3000 / ?mock_jitterreal time passes โ€” your AbortSignal.timeout genuinely fires (measured)
Ordered .reply() chains for retries?mock_seq=503,503,200deterministic sequence, mock_seq_key per worker (verified recovery)
(no flakiness simulation)?mock_chaos=0.5real 5xx/429s for your retry logic to absorb
Config/env upstream URLenv-var base URLpoint UPSTREAM at the mock; app code doesn't change
scope.done() assertionsrequest inspectorlast 50 requests with method, path, query, headers, body
nock.enableNetConnect(host)n/akeep using it โ€” allow 127.0.0.1 for supertest and the hosted mock's origin for the rest

Try it in 10 seconds (shared demo, no setup)

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).

Or use both โ€” they compose

Honest comparison

supertest + nockMockbird
What it isnpm libraries in your test processfree hosted service
Reachable fromHTTP calls in the test process onlyanything with HTTP: browser, Playwright, curl, spawned servers, workers, mobile, CI, teammates
Node 22 native fetchintercepted since nock 14 (verified)it's a real URL โ€” every client works by definition
Unmatched requestloud 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
Setupregister stubs per testone curl or one click; no code
Stateful CRUDno โ€” scripted answers onlydefault โ€” 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 offlineyesno โ€” it's a real network call
Request assertionsscope.done(), precise, in-testrequest inspector (last 50, headers/body)
Request capnone10,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 โ†’