Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/nodejs/undici/llms.txt

Use this file to discover all available pages before exploring further.

Testing code that uses undici requires attention to two practical concerns: by default undici keeps sockets open for a few seconds after each request to reduce connection overhead in production, which causes tests to hang; and real HTTP requests make tests slow, non-deterministic, and fragile. This guide covers both concerns — adjusting timeouts for tests, writing unit tests with MockAgent, and using SnapshotAgent for integration record-and-replay — along with patterns for guarding against silent reconnection bugs.

Adjusting socket timeouts for tests

Undici’s production defaults hold connections open for several seconds. In a test suite this means the process won’t exit until all keep-alive timers fire. Set short timeouts to fix this:
Short keep-alive timeouts for tests
import { request, setGlobalDispatcher, Agent } from 'undici'

const agent = new Agent({
  keepAliveTimeout: 10,    // milliseconds
  keepAliveMaxTimeout: 10  // milliseconds
})

setGlobalDispatcher(agent)
Apply this once in a global test setup file (e.g. test/setup.js) so every test in the suite benefits automatically.

Unit testing with MockAgent

MockAgent intercepts undici requests and returns configured responses without touching the network. The core pattern is:
1

Create and activate MockAgent

import { MockAgent, setGlobalDispatcher } from 'undici'

const mockAgent = new MockAgent()
mockAgent.disableNetConnect() // throw on unmatched requests
setGlobalDispatcher(mockAgent)
2

Register interceptors

const mockPool = mockAgent.get('http://localhost:3000')

mockPool.intercept({
  path: '/bank-transfer',
  method: 'POST',
  headers: { 'X-TOKEN-SECRET': 'SuperSecretToken' }
}).reply(200, { message: 'transaction processed' })
3

Run the code under test

const result = await bankTransfer('1234567890', '100')
assert.deepEqual(result, { message: 'transaction processed' })
4

Assert all interceptors were called

mockAgent.assertNoPendingInterceptors()
await mockAgent.close()

Complete unit test example

bank.mjs — the module under test
import { request } from 'undici'

export async function bankTransfer (recipient, amount) {
  const { body } = await request('http://localhost:3000/bank-transfer', {
    method: 'POST',
    headers: { 'X-TOKEN-SECRET': 'SuperSecretToken' },
    body: JSON.stringify({ recipient, amount })
  })
  return await body.json()
}
bank.test.mjs — the test file
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { MockAgent, setGlobalDispatcher } from 'undici'
import { bankTransfer } from './bank.mjs'

test('bankTransfer — success', async () => {
  const mockAgent = new MockAgent()
  mockAgent.disableNetConnect()
  setGlobalDispatcher(mockAgent)

  const mockPool = mockAgent.get('http://localhost:3000')

  mockPool.intercept({
    path: '/bank-transfer',
    method: 'POST',
    headers: { 'X-TOKEN-SECRET': 'SuperSecretToken' },
    body: JSON.stringify({ recipient: '1234567890', amount: '100' })
  }).reply(200, { message: 'transaction processed' })

  const result = await bankTransfer('1234567890', '100')
  assert.deepEqual(result, { message: 'transaction processed' })

  mockAgent.assertNoPendingInterceptors()
  await mockAgent.close()
})

test('bankTransfer — account not found', async () => {
  const mockAgent = new MockAgent()
  mockAgent.disableNetConnect()
  setGlobalDispatcher(mockAgent)

  const mockPool = mockAgent.get('http://localhost:3000')

  mockPool.intercept({
    path: '/bank-transfer',
    method: 'POST'
  }).reply(400, { message: 'bank account not found' })

  const result = await bankTransfer('0000000000', '100')
  assert.deepEqual(result, { message: 'bank account not found' })

  await mockAgent.close()
})

Testing with Jest

The same pattern works with Jest — just swap node:test for Jest’s test / expect:
bank.test.js (Jest)
import { MockAgent, setGlobalDispatcher } from 'undici'
import { bankTransfer } from './bank.mjs'

describe('bankTransfer', () => {
  let mockAgent

  beforeEach(() => {
    mockAgent = new MockAgent()
    mockAgent.disableNetConnect()
    setGlobalDispatcher(mockAgent)
  })

  afterEach(async () => {
    await mockAgent.close()
  })

  it('returns success response', async () => {
    const mockPool = mockAgent.get('http://localhost:3000')

    mockPool.intercept({
      path: '/bank-transfer',
      method: 'POST'
    }).reply(200, { message: 'transaction processed' })

    const result = await bankTransfer('1234567890', '100')
    expect(result).toEqual({ message: 'transaction processed' })

    mockAgent.assertNoPendingInterceptors()
  })
})
Wrapping agent setup in beforeEach / afterEach (or their node:test equivalents before / after) ensures a clean slate for every test and prevents interceptors from leaking between tests.

Mocking multiple endpoints

Register as many interceptors as needed on one or more pools. Each interceptor is consumed by one matching request by default — call .persist() to reuse it indefinitely or .times(n) for a specific count.
Multiple interceptors
import { MockAgent, setGlobalDispatcher, request } from 'undici'

const mockAgent = new MockAgent()
setGlobalDispatcher(mockAgent)

const mockPool = mockAgent.get('http://localhost:3000')

mockPool.intercept({ path: '/foo',   method: 'GET' }).reply(200, 'foo')
mockPool.intercept({ path: '/hello', method: 'GET' }).reply(200, 'hello')
// Health check endpoint called many times
mockPool.intercept({ path: '/health', method: 'GET' }).reply(200, 'ok').persist()

const result1 = await request('http://localhost:3000/foo')
const result2 = await request('http://localhost:3000/hello')
// /health can be called any number of times

Simulating errors

Use .replyWithError() to simulate network errors or server failures:
Simulating a network error
import { MockAgent, request } from 'undici'

const mockAgent = new MockAgent()
const mockPool = mockAgent.get('http://localhost:3000')

mockPool.intercept({ path: '/flaky', method: 'GET' })
  .replyWithError(new Error('connection reset by peer'))

try {
  await request('http://localhost:3000/flaky', { dispatcher: mockPool })
} catch (error) {
  console.error(error.cause) // Error: connection reset by peer
}

Asserting request details with call history

Enable call history to make assertions on exactly what your code sent — headers, body, query parameters, and more:
Inspecting request call history
import { strict as assert } from 'node:assert'
import { MockAgent, setGlobalDispatcher, fetch } from 'undici'

const mockAgent = new MockAgent({ enableCallHistory: true })
setGlobalDispatcher(mockAgent)

await fetch(`http://localhost:3000/endpoint?query=hello`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ data: '' })
})

const history = mockAgent.getCallHistory()

assert.strictEqual(history?.calls().length, 1)
assert.strictEqual(history?.firstCall()?.method, 'POST')
assert.strictEqual(history?.firstCall()?.path, '/endpoint')
assert.deepStrictEqual(history?.firstCall()?.searchParams, { query: 'hello' })
assert.deepStrictEqual(history?.firstCall()?.headers, { 'content-type': 'application/json' })

mockAgent.clearCallHistory()

Integration testing with SnapshotAgent

SnapshotAgent extends MockAgent with automatic record-and-replay. Run your tests once in record mode against a real API to capture responses, then switch to playback mode so the tests run offline against the saved snapshots.
Recording real API responses
import { SnapshotAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'

// Run this once with SNAPSHOT_MODE=record
const agent = new SnapshotAgent({
  mode: 'record',
  snapshotPath: './test/snapshots/api-test.json'
})
setGlobalDispatcher(agent)

// Real HTTP call — gets recorded
const response = await fetch('https://api.example.com/users')
const users = await response.json()

await agent.saveSnapshots()

Environment-based mode switching

Switching modes via environment variable
const mode = process.env.SNAPSHOT_MODE || 'playback'

const agent = new SnapshotAgent({
  mode,
  snapshotPath: './test/snapshots/integration.json'
})

// Record:   SNAPSHOT_MODE=record node test.js
// Playback: node test.js

Integration test with node:test

Complete integration test using SnapshotAgent
import { test } from 'node:test'
import { strict as assert } from 'node:assert'
import { SnapshotAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'

test('user API returns an array of users', async (t) => {
  const originalDispatcher = getGlobalDispatcher()

  const agent = new SnapshotAgent({
    mode: process.env.SNAPSHOT_MODE || 'playback',
    snapshotPath: './test/snapshots/user-api.json'
  })
  setGlobalDispatcher(agent)

  // Restore original dispatcher after the test
  t.after(() => setGlobalDispatcher(originalDispatcher))

  const response = await fetch('https://jsonplaceholder.typicode.com/users')
  const users = await response.json()

  assert.ok(Array.isArray(users), 'response should be an array')
  assert.ok(users.length > 0, 'array should be non-empty')
  assert.ok('id' in users[0], 'each user should have an id')
})

Guarding against silent disconnects

Undici’s Client automatically reconnects after a socket error. In tests this can mask bugs — a request silently disconnects, reconnects, and succeeds when it should have failed. Add a disconnect guard to catch these cases:
Disconnect guard for Client tests
const { Client } = require('undici')
const { test, after } = require('node:test')
const { tspl } = require('@matteo.collina/tspl')

test('example with disconnect guard', async (t) => {
  t = tspl(t, { plan: 1 })

  const client = new Client('http://localhost:3000')
  after(() => client.close())

  client.on('disconnect', () => {
    // close() and destroy() also emit 'disconnect' — ignore expected ones
    if (!client.closed && !client.destroyed) {
      t.fail('unexpected disconnect')
    }
  })

  // ... test logic ...
})
Skip the disconnect guard for tests that intentionally trigger disconnects: signal aborts, server-side socket destruction, body stream destruction, timeout errors, successful protocol upgrades, and HTTP parser errors from malformed responses.

Best practices summary

Reset between tests

Create a fresh MockAgent in each test’s setup and close it in teardown to prevent interceptors leaking across tests.

Always disableNetConnect

Call mockAgent.disableNetConnect() so unmatched requests throw immediately rather than silently hitting the network.

Assert pending interceptors

Call assertNoPendingInterceptors() at the end of each test to verify every mocked endpoint was actually exercised.

Commit sanitized snapshots

Commit SnapshotAgent files to version control so CI always runs in playback mode. Exclude sensitive headers with excludeHeaders.

Build docs developers (and LLMs) love