Article · Network & API Mocking for Reliable Tests

Replaying HTTP Traffic in Jest with nock

Browser-level interception cannot see a request your Node code makes — a server-side data fetch, a webhook handler, a background sync job. Those calls need interception at the HTTP module, which is what nock provides, together with a recorder that captures real exchanges and replays them offline. This guide is the Node-side counterpart to the browser techniques in Record & Replay HTTP Traffic.

12 sections URL: /network-api-mocking-for-reliable-tests/record-and-replay-http-traffic/replaying-http-traffic-in-jest-with-nock/
Where each interception layer sits Browser route interception covers requests from the page; nock covers requests made by Node; neither sees the other's traffic. browser process page fetch / XHR intercepted by page.route / cy.intercept nock cannot see any of this Node process server-side fetch, SDK calls intercepted by nock route handlers cannot see any of this a full-stack test needs both layers — mocking one and assuming the other is covered is a common blind spot
The two interception layers are disjoint; each is blind to the traffic the other handles.

Root cause #

Interception works by replacing something. Browser tools replace the network layer of the page, so they see requests initiated by page scripts and nothing else. nock replaces Node’s http and https request machinery, so it sees requests made by anything in the process that ultimately goes through those modules — including most HTTP clients and provider SDKs, because they are built on top of them.

That mechanism explains both its power and its limits. A client using Node’s built-in fetch (which is served by undici rather than the classic http stack) may not be intercepted by older versions of nock, and a call made by a spawned process or a native module is outside the process entirely. When mocks silently fail to apply, the request goes to the real host — the test passes because the real API answered, and then fails weeks later when that host is unreachable. nock.disableNetConnect() is what converts that silent escape into an immediate, explicit error, and it is the single most valuable line in a nock setup.

The second mechanism is interceptor consumption. A nock interceptor is single-use by default: it matches one request and is then spent. A test that retries, or a client with built-in retry logic, therefore succeeds on the first attempt and hits an unmatched-request error on the second — which reads as a bizarre intermittent failure until you know the rule. .times(n) or .persist() express the intended multiplicity, and leaving them out is how retry-aware code produces confusing test results.

Step-by-step fix #

1. Record real exchanges once #

The recorder captures every outbound request during a run and returns them as definitions you can write to a fixture.

// scripts/record-billing.js
// Trade-off: recording captures whatever the code actually did, including
// calls you did not know about — read the output before committing it.
import nock from 'nock';
import { writeFileSync } from 'node:fs';
import { syncInvoices } from '../src/billing/sync.js';

nock.recorder.rec({ output_objects: true, dont_print: true, enable_reqheaders_recording: false });
await syncInvoices({ tenant: 'acme' });
const definitions = nock.recorder.play();
writeFileSync('fixtures/billing.json', JSON.stringify(definitions, null, 2));

Leaving request-header recording off is deliberate: recorded headers include Authorization, and a fixture that matches on them will also contain them.

2. Replay with the network switched off #

Load the definitions and forbid real connections, so an unmocked call fails immediately instead of reaching the internet.

// tests/setup-nock.js
// Trade-off: disableNetConnect blocks localhost too, which breaks tests against
// a local server unless you allow it explicitly.
import nock from 'nock';

beforeAll(() => {
  nock.disableNetConnect();
  nock.enableNetConnect('127.0.0.1');    // allow a local test server, nothing else
});

afterEach(() => {
  // Fail loudly if a test set up interceptors it never used — usually a sign
  // the code path changed and the fixture no longer matches.
  const pending = nock.pendingMocks();
  nock.cleanAll();
  if (pending.length) throw new Error(`unused interceptors:\n${pending.join('\n')}`);
});

afterAll(() => nock.enableNetConnect());

The pendingMocks assertion is the counterpart to disableNetConnect: one catches calls you did not mock, the other catches mocks nothing called. Together they keep the fixture and the code honest in both directions.

Two checks that keep fixtures aligned with the code disableNetConnect catches unmocked requests; pendingMocks catches interceptors that were never used. request with no interceptor disableNetConnect() → error otherwise: silent live call interceptor with no request pendingMocks() → error otherwise: dead fixture, false confidence each check catches the failure the other cannot see
Without both checks a suite can be simultaneously calling the real API and asserting against fixtures nothing uses.

3. Express multiplicity explicitly #

Decide, per interceptor, how many times it should match. Retry-aware clients make this the difference between a stable test and an inexplicable one.

// Trade-off: persist() is convenient and hides how many calls the code makes;
// times(n) is stricter and documents the expected request count.
nock('https://billing.example.com')
  .get('/v1/invoices')
  .times(3)                        // the client retries twice on 5xx
  .reply(500, { error: 'upstream' });

nock('https://billing.example.com')
  .get('/v1/invoices')
  .reply(200, { invoices: [] });   // the fourth attempt succeeds

That pattern also makes retry behaviour testable: the assertion that exactly three failures preceded the success is now encoded in the fixture, which is far clearer than counting calls in a spy.

4. Match on the parts that matter, ignore the parts that do not #

Over-specified matching produces brittle tests; under-specified matching returns the wrong response. Match on method, path and the query keys that change the answer, and use a predicate for bodies where only some fields matter.

// Trade-off: a body predicate is more permissive than a literal body and
// survives harmless additions like a client version field.
nock('https://billing.example.com')
  .post('/v1/charges', (body) => body.amount === 1250 && body.currency === 'EUR')
  .query({ tenant: 'acme' })
  .reply(201, { id: 'ch_1', status: 'succeeded' });

5. Keep fixtures per feature and scrub them #

The same rules as for browser archives apply: one fixture per feature, secrets redacted, volatile values normalised. A recorded definition file is source code that happens to be JSON, and it deserves the same review.

// scripts/scrub-nock-fixture.js
// Trade-off: scrubbing headers can break matching if a test relies on one;
// prefer not matching on headers at all, which makes scrubbing safe.
const defs = JSON.parse(readFileSync(file, 'utf8'));
for (const d of defs) {
  delete d.reqheaders;
  if (d.rawHeaders) d.rawHeaders = d.rawHeaders.map((v, i) =>
    /authorization|set-cookie/i.test(d.rawHeaders[i - 1] ?? '') ? 'REDACTED' : v);
}
writeFileSync(file, JSON.stringify(defs, null, 2));

6. Check the interception layer actually applies #

Before trusting a suite, prove that a request would fail if it were not mocked. A single test that expects a rejection when no interceptor is registered confirms the whole mechanism is in place.

// Trade-off: a meta-test that verifies the harness rather than the product,
// and it is what catches an HTTP client that quietly bypasses interception.
test('outbound HTTP is intercepted', async () => {
  await expect(fetch('https://billing.example.com/v1/ping'))
    .rejects.toThrow(/Nock: Disallowed net connect/);
});

If that test passes when it should fail, the client is using a stack nock does not patch — the point at which you either change the client or move the mocking to a layer that does see it, such as MSW’s Node server as described in MSW in JavaScript Test Suites.

Pitfalls #

  • Not calling disableNetConnect(). Unmatched requests reach the real host and the suite depends on it silently. Mitigation: disable by default, allow only localhost.
  • Leaving interceptors uncleaned between tests. A spent or leftover interceptor changes the next test’s behaviour. Mitigation: cleanAll() in afterEach, and assert pendingMocks() is empty.
  • Assuming one interceptor covers a retrying client. The second attempt is unmatched. Mitigation: use .times(n) to state the expected count.
  • Matching on recorded request headers. Tokens end up in the fixture and matching breaks when a header changes. Mitigation: do not record or match headers unless the behaviour depends on them.
  • Using a client nock does not patch. Mocks silently do not apply. Mitigation: verify with the interception meta-test.
  • One fixture file for the whole suite. Every test depends on every recording. Mitigation: one file per feature, loaded by the tests that need it.
  • Asserting on recorded timestamps. The fixture ages and the assertion breaks. Mitigation: normalise volatile fields at scrub time.
Interceptor lifetime and retrying clients A single-use interceptor answers the first attempt; the retry finds nothing and fails unless multiplicity is declared. attempt 1 interceptor matches, spent attempt 2 (retry) no match — disallowed connect .times(2) or .persist()states the expectation
The single-use default is a feature — it forces the test to state how many calls the code should make.

Reliability targets #

Metric Target Notes
Live outbound calls in CI 0 Guaranteed by disableNetConnect()
Tests ending with unused interceptors 0 Asserted in afterEach
Fixtures containing credentials 0 Scrubbed and checked in the pipeline
Interceptors with explicit multiplicity 100% where the client retries .times(n) over .persist()
Fixture age < 30 days Same re-record cadence as browser archives
Node interception scorecard Targets for live calls, unused interceptors, credentials in fixtures and fixture age. 0live calls 0unused interceptors 0credentials stored < 30 dfixture age
The first two numbers are enforceable in setup code, which is what makes them stay at zero.

Frequently Asked Questions #

Q: My mocks are ignored and the real API is called. What is wrong? A: The client is not going through the stack nock patches. Native fetch in newer Node runtimes and some SDKs use undici directly, which older versions do not intercept. Upgrade nock, configure the client to use a patched agent, or move that integration’s mocking to a layer that sees it. The interception meta-test in step 6 turns this from a mystery into a one-line diagnosis.

Q: Should I use nock or MSW for Node tests? A: MSW is the better choice when the same handlers should serve both browser and Node tests, because you write the contract once and reuse it — see Sharing MSW Handlers Between Jest and Playwright. nock is the better choice when you want recording, per-interceptor multiplicity and strict call-count assertions, which suit integration tests around a provider SDK.

Q: Why does my test fail with “no match for request” only sometimes? A: Almost always retries or concurrency. A single-use interceptor is consumed by whichever request arrives first, so a client that retries or fires two requests in parallel leaves one unmatched. Declare the multiplicity, and check whether the code makes the number of calls you think it does.

Q: Is it safe to commit recorded definitions to the repository? A: Once they are scrubbed, yes — and it is preferable, because a contract change then shows up as a reviewable diff. Strip request headers, redact any credential-shaped value, normalise timestamps and ids, and add a pipeline check that fails if a token pattern reappears.