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.
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()inafterEach, and assertpendingMocks()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
nockdoes 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.
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 |
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.