How-to › Test and mock integrations

How to stub outbound HTTP calls in Node tests with nock#

Intercept outbound requests in-process, assert on what your code sent as well as received, and turn off real network access so an unstubbed call fails.

Audience
API consumer
Level
intermediate
Topic
Mock an API
Languages
TypeScript and JavaScript
Verified

A test suite that reaches a real API fails on somebody else’s deploy, at somebody else’s rate limit, and on the day the office network is slow. It also passes for the wrong reasons: a test that asserts on a 200 cannot tell a working integration from a vendor sandbox that returns 200 for everything you send it.

What you get

You will end up with a suite that cannot reach the network, stubs each call by host and path, and fails when your code makes a request no test expected. This is for you if your tests call out.

Short answer

Call nock.disableNetConnect once for the suite, then declare an interceptor per test with the host, path, and expected request headers. Assert with isDone that the interceptor was used, so a test cannot pass because your code never made the call. Clean interceptors between tests, because one left behind answers a request in a later test.

You will need

Node 22 or later, and code that makes outbound calls. Version 14 of nock intercepts the global fetch as well as http.request, which matters because a mock that only covers one of those leaves half a modern codebase unstubbed.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A local test serverYou want no mocking library and full control of the responsesYour code has to accept a base URL, and every test manages a port and a lifecycleThe call is buried in a dependency you cannot point elsewhere
MSWThe same handlers should serve browser tests, Node tests and local developmentA larger tool with its own handler model, which is more to learn for a backend-only suiteOnly Node tests need stubbing
nockNode suites that want per-test stubs and assertions on the requestInterceptors are consumed once, so a retry test needs the stub declared as many timesThe code under test talks to a database or a queue rather than HTTP
undici MockAgentYou already build requests through an undici dispatcherIt covers undici’s own paths, so a library using node:http directly is untouchedCalls are made by several different clients

The local server and the interceptor differ in what they can reach. A server needs the code under test to be pointed at it, which a well-designed client allows and a third-party dependency usually does not. An interceptor works at the HTTP layer, so it catches calls from code you cannot configure.

Turn the network off first

The single most valuable line is the one that makes an unstubbed call an error.

before(() => {
  // Any request the test suite did not stub is a real network call, and a test
  // suite that can reach the internet is a test suite that fails at somebody
  // else's deploy. This makes an unstubbed call an error instead.
  nock.disableNetConnect()
})
beforeEach(() => nock.cleanAll())
after(() => { nock.cleanAll(); nock.enableNetConnect() })

Without it, a call your stubs do not match goes to the real host. The test then either passes because the vendor answered, or fails with a network error that reads like a bug in your code. With it, the same call fails immediately and names the host it tried to reach.

Cleaning between tests is the other half. An interceptor that a test declared and never used stays armed, and the next test that happens to call the same path gets the previous test’s response. That failure is hard to find because it depends on test order.

Assert on what you sent

A stub is a chance to check the request, not only to supply a response.

test('the stub asserts on the request as well as answering it', async () => {
  const scope = nock('https://api.example.com', {
    reqheaders: { authorization: 'Bearer sk-test-1' },
  })
    .get('/v1/invoices/in_2')
    .reply(200, { id: 'in_2' })

  await fetchInvoice('in_2')

  assert.equal(scope.isDone(), true, 'the interceptor was used')
})

The reqheaders option makes the interceptor match only when the header is present and correct. A request missing it does not match, so it falls through to the blocked network and fails. The header assertion and the response stub are the same declaration.

isDone is what stops a false pass. Without it a test that never calls the API still succeeds, because nothing checks that the interceptor was reached, and a refactor that removes the call entirely leaves the suite green.

Check it worked

Four tests: a stubbed response, an assertion on the request, a 404 the client maps to null, and a call nobody stubbed.

node --test nock.test.mjs
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 228.192563

The fourth test is the one worth reading twice. It asserts that an unstubbed call rejects, which proves the suite is closed rather than assuming it.

Running the same file with the network left on is the way to see the difference. Comment out disableNetConnect, change one path so the stub no longer matches, and the request leaves the machine. Whether that fails depends on the network and on the vendor, which is the property a test suite should never have.

The other thing to check once is the interception surface. A dependency that uses node:http directly, rather than the global fetch, is covered by the same interceptors. That is the reason to prefer an HTTP-layer mock over passing a fake client into your own code.

When it goes wrong

A retry test passes on the first attempt and fails on the second. An interceptor answers one request and is then spent, so code that retries needs the stub declared for each attempt, or .times(3). The failure looks like your retry logic misbehaving when the stub ran out instead.

The second failure is a stub that does not match and gives no reason. A path with a query string, a trailing slash, or a body that differs by whitespace all fail to match, and the visible result is only that the request escaped. Print nock.pendingMocks() in the failing test, which lists the interceptors still waiting and the exact shapes they expect.

The third is a suite that stubs so precisely it tests the stub. A test asserting on a fixed response body it wrote itself proves that a constant equals a constant. Keep the stub thin and assert on what your code did with it.

When not to do this

Do not stub the API in every test in the suite. The stubs encode your belief about the vendor, and a suite made only of stubs passes whatever the vendor is doing, which is what a scheduled contract check exists to cover.

Do not use interceptors to test your own service’s endpoints. Those you can call for real, in process, and a stub between two pieces of your own code hides the integration that is worth testing.

Do not leave disableNetConnect out because one test needs the network. Allow that host explicitly with nock.enableNetConnect(host), so the exception is visible and narrow rather than a suite-wide gap nobody remembers.

Do not record stubs from production traffic without reading them. A recorded interaction carries whatever headers and identifiers were in flight, and those end up committed to a repository.

Last verified

Verified 2026-09-06 against Node 22.22.2 and nock 14.0.10. The output block is what the preceding command printed, with the network turned off for the whole run.

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 22 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.