/ #node:test #testing #node 

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'❌'01  └─────────┴────────┴──────────────────────────┴─────────────────┴─────────────┴────────────┴─────────────┴───────────┘
      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

Author

Hugo Di Francesco

Co-author of "Professional JavaScript", "Front-End Development Projects with Vue.js" with Packt, "The Jest Handbook" (self-published). Hugo runs the Code with Hugo website helping over 100,000 developers every month and holds an MEng in Mathematical Computation from University College London (UCL). He has used JavaScript extensively to create scalable and performant platforms at companies such as Canon, Elsevier and (currently) Eurostar.

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.