Test Native `fetch` in Node.js with Undici interception and mock utils
Node.js 18+ has a built-in fetch
available, where prior versions had to use libraries such a node-fetch
, axios
, got
or other to get such functionality.
The “native fetch” was implemented in userland first as the undici package.
This post goes through how to use undici
’s mock utilities (MockAgent
, MockPool
, setGlobalDispatcher
) to intercept “native fetch
” requests in Node.js 18+.
Table of Contents
HTTP interception libraries implemented on top of Node’s http
module won’t work when using global.fetch
(ie. native fetch in Node.js), see the following issues on the nock
repository:
The example that follows is available at node-test-runner-examples/src/06.02-native-fetch-intercept.test.js
Setting up an undici MockAgent and overriding global.fetch
Unfortunately we can’t import the undici module that’s “bundled” as part of Node.js from our code (import 'undici';
fails with “Cannot find package ‘undici’”).
npm install --save-dev undici
In order to use intercept requests with undici, we need to create a mockAgent
using new MockAgent()
.
Then in our setup step (before()
), we override the “global dispatcher” to be our mockAgent
using setGlobalDispatcher(mockAgent)
, we also disable “real” connectivity, so any fetch
calls have to matched by in our mockAgent
otherwise fetch will throw.
In our teardown step (after()
), we call mockAgent.close()
to teardown the agent and reset it using setGlobalDispatcher(new Agent())
.
import { before, after } from 'node:test';
import { Agent, MockAgent, setGlobalDispatcher } from 'undici';
const mockAgent = new MockAgent();
before(() => {
setGlobalDispatcher(mockAgent);
mockAgent.disableNetConnect();
});
after(async () => {
await mockAgent.close();
setGlobalDispatcher(new Agent());
});
We’ve now seen how to instantiate an undici MockAgent and how to configure it during node:test
setup and teardown via before()
and after()
. Next we’ll intercept a fetch
request using MockAgent’s get
method.
Intercepting requests with MockAgent().get().intercept()
We can now start writing our test.
To intercept requests to https://api.github.com/users/HugoDF
we start by setting up a MockPool
to intercept the api.github.com
domain using mockAgent.get('https://api.github.com')
. Then we need to match the specific path using MockPool().intercept({ path: '/users/HugoDF' })
. Finally we set the reply using .reply(statusCode, responseData)
.
We’re done setting up interception with undici, we can run fetch('https://api.github.com/users/HugoDF').then(res => res.json)
and assert that the output is equal to the contents of .reply()
.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
// no change to undici import
// no change to mockAgent creation, before() or after()
test('native fetch interception via undici', async () => {
mockAgent
.get('https://api.github.com')
.intercept({ path: '/users/HugoDF' })
.reply(200, {
blog: 'https://codewithhugo.com',
location: 'London',
bio: 'Developer, JavaScript',
public_repos: 39,
});
const data = await fetch('https://api.github.com/users/HugoDF').then((res) =>
res.json()
);
assert.deepEqual(data, {
blog: 'https://codewithhugo.com',
location: 'London',
bio: 'Developer, JavaScript',
public_repos: 39,
});
});
When we run this node:test
test file, we get the following output. Note the runtime which tends to be in the 10-20ms range, where an HTTP request that actually hits api.github.com
would probably take at least 100ms.
node src/06.02-native-fetch-intercept.test.js
✔ native fetch interception via undici (11.783833ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 14.963958
We’ve now seen how
Ensuring that all configuring MockAgent intercepts have run
For ease of maintainability or debugging, it can be useful to ensure all defined undici#MockAgent
“intercept”’s that have been configured have been run. There are 2 ways to do this.
undici#MockAgent
provides a mockAgent.assertNoPendingInterceptors()
assertion. Alternatively we can assert on the contents of mockAgent.pendingInterceptors()
.
These would look as follows in our existing test:
// no change to imports
// no change to mockAgent creation, before() or after()
test('native fetch interception via undici', async () => {
// no change to the rest of the test
mockAgent.assertNoPendingInterceptors();
assert.deepEqual(mockAgent.pendingInterceptors(), []);
});
For example if we comment out our fetch()
call and assertion on the output data, mockAgent.assertNoPendingInterceptors()
throws and we get the following test output:
node src/06.02-native-fetch-intercept.test.js
✖ native fetch interception via undici (2.828625ms)
UndiciError: 1 interceptor is pending:
┌─────────┬────────┬──────────────────────────┬─────────────────┬─────────────┬────────────┬─────────────┬───────────┐
│ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │
├─────────┼────────┼──────────────────────────┼─────────────────┼─────────────┼────────────┼─────────────┼───────────┤
│ 0 │ 'GET' │ 'https://api.github.com' │ '/users/HugoDF' │ 200 │ '❌' │ 0 │ 1 │
└─────────┴────────┴──────────────────────────┴─────────────────┴─────────────┴────────────┴─────────────┴───────────┘
at MockAgent.assertNoPendingInterceptors (/projects/node-test-runner-examples/node_modules/undici/lib/mock/mock-agent.js:152:11)
at TestContext.<anonymous> (file:///projects/node-test-runner-examples/src/06.02-native-fetch-intercept.test.js:31:12)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:631:25)
at Test.start (node:internal/test_runner/test:542:17)
at startSubtest (node:internal/test_runner/harness:216:17) {
code: 'UND_ERR'
}
An assert.deepEqual(mockAgent.pendingInterceptors(), []);
failure looks as follows:
node src/06.02-native-fetch-intercept.test.js
✖ native fetch interception via undici (3.354042ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
+ [
+ {
+ body: undefined,
+ consumed: false,
+ data: {
+ data: {
+ bio: 'Developer, JavaScript',
+ blog: 'https://codewithhugo.com',
+ location: 'London',
+ public_repos: 39
+ },
+ error: null,
+ headers: {},
+ statusCode: 200,
+ trailers: {}
+ },
+ headers: undefined,
+ method: 'GET',
+ origin: 'https://api.github.com',
+ path: '/users/HugoDF',
+ pending: true,
+ persist: false,
+ query: undefined,
+ times: 1,
+ timesInvoked: 0
+ }
+ ]
- []
at TestContext.<anonymous> (file:///projects/node-test-runner-examples/src/06.02-native-fetch-intercept.test.js:33:9)
at Test.runInAsyncScope (node:async_hooks:206:9)
at Test.run (node:internal/test_runner/test:631:25)
at Test.start (node:internal/test_runner/test:542:17)
at startSubtest (node:internal/test_runner/harness:216:17) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [Array],
expected: [],
operator: 'deepStrictEqual'
}
In conclusion regarding asserting on pending requests, prefer mockAgent.assertNoPendingInterceptors()
if there should be no pending interceptors since the output is better formatted for debugging. Prefer mockAgent.pendingInterceptors()
if the goal is to assert on the contents of the pending interceptors.
Further Reading
Useful Undici Docs links
Get The Jest Handbook (100 pages)
Take your JavaScript testing to the next level by learning the ins and outs of Jest, the top JavaScript testing library.
orJoin 1000s of developers learning about Enterprise-grade Node.js & JavaScript