On this page

The SnapshotAgent provides a powerful way to record and replay HTTP requests for testing purposes. It extends MockAgent to enable automatic snapshot testing, eliminating the need to manually define mock responses.

  • Integration Testing: Record real API interactions and replay them in tests
  • Offline Development: Work with APIs without network connectivity
  • Consistent Test Data: Ensure tests use the same responses across runs
  • API Contract Testing: Capture and validate API behavior over time
  • options Object (optional)
    • mode String - The snapshot mode: 'record', 'playback', or 'update'. Default: 'record'
    • snapshotPath String - Path to the snapshot file for loading/saving
    • maxSnapshots Number - Maximum number of snapshots to keep in memory. Default: Infinity
    • autoFlush Boolean - Whether to automatically save snapshots to disk. Default: false
    • flushInterval Number - Interval in milliseconds for auto-flush. Default: 30000
    • matchHeaders Array<String> - Specific headers to include in request matching. Default: all headers
    • ignoreHeaders Array<String> - Headers to ignore during request matching
    • excludeHeaders Array<String> - Headers to exclude from snapshots (for security)
    • matchBody Boolean - Whether to include request body in matching. Default: true
    • normalizeBody Function - Optional function (body) => string to normalize the request body before matching (e.g. strip volatile fields like timestamps). Only used when matchBody is true.
    • matchQuery Boolean - Whether to include query parameters in matching. Default: true
    • normalizeQuery Function - Optional function (query: URLSearchParams) => string to normalize query parameters before matching (e.g. strip volatile params like cache-busters). Only used when matchQuery is true.
    • caseSensitive Boolean - Whether header matching is case-sensitive. Default: false
    • shouldRecord Function - Callback to determine if a request should be recorded
    • shouldPlayback Function - Callback to determine if a request should be played back
    • excludeUrls Array - URL patterns (strings or RegExp) to exclude from recording/playback
    • All other options from MockAgent are supported

Makes real HTTP requests and saves the responses to snapshots.

import { SnapshotAgent, setGlobalDispatcher } from 'undici'

const agent = new SnapshotAgent({ 
  mode: 'record',
  snapshotPath: './test/snapshots/api-calls.json'
})
setGlobalDispatcher(agent)

// Makes real requests and records them
const response = await fetch('https://api.example.com/users')
const users = await response.json()

// Save recorded snapshots
await agent.saveSnapshots()

Replays recorded responses without making real HTTP requests.

import { SnapshotAgent, setGlobalDispatcher } from 'undici'

const agent = new SnapshotAgent({
  mode: 'playback',
  snapshotPath: './test/snapshots/api-calls.json'
})
setGlobalDispatcher(agent)

// Uses recorded response instead of real request
const response = await fetch('https://api.example.com/users')

Uses existing snapshots when available, but records new ones for missing requests.

import { SnapshotAgent, setGlobalDispatcher } from 'undici'

const agent = new SnapshotAgent({
  mode: 'update',
  snapshotPath: './test/snapshots/api-calls.json'
})
setGlobalDispatcher(agent)

// Uses snapshot if exists, otherwise makes real request and records it
const response = await fetch('https://api.example.com/new-endpoint')
agent.saveSnapshots(filePath?): void

Saves all recorded snapshots to a file.

  • filePath String (optional) - Path to save snapshots. Uses constructor snapshotPath if not provided.

Promise<void>

By default (matchBody: true) the full request body string is included in the snapshot key. Set it to false to ignore the body entirely, or use normalizeBody to strip volatile fields (like timestamps) before matching:

const agent = new SnapshotAgent({
  mode: 'playback',
  snapshotPath: './snapshots.json',

  // Match on everything except the timestamp field
  normalizeBody: (body) => {
    if (!body) return ''
    const parsed = JSON.parse(String(body))
    delete parsed.timestamp
    return JSON.stringify(parsed)
  }
})

normalizeBody receives the raw body (string | Buffer | null | undefined) and must return a string. It runs at both record and playback time so the hash is consistent. Two requests match the same snapshot whenever their normalized strings are identical.

Control which headers are used for request matching and what gets stored in snapshots:

const agent = new SnapshotAgent({
  mode: 'record',
  snapshotPath: './snapshots.json',
  
  // Only match these specific headers
  matchHeaders: ['content-type', 'accept'],
  
  // Ignore these headers during matching (but still store them)
  ignoreHeaders: ['user-agent', 'date'],
  
  // Exclude sensitive headers from snapshots entirely
  excludeHeaders: ['authorization', 'x-api-key', 'cookie']
})

Use callback functions to determine what gets recorded or played back:

const agent = new SnapshotAgent({
  mode: 'record',
  snapshotPath: './snapshots.json',
  
  // Only record GET requests to specific endpoints
  shouldRecord: (requestOpts) => {
    const url = new URL(requestOpts.path, requestOpts.origin)
    return requestOpts.method === 'GET' && url.pathname.startsWith('/api/v1/')
  },
  
  // Skip authentication endpoints during playback
  shouldPlayback: (requestOpts) => {
    const url = new URL(requestOpts.path, requestOpts.origin)
    return !url.pathname.includes('/auth/')
  }
})

Exclude specific URLs from recording/playback using patterns:

const agent = new SnapshotAgent({
  mode: 'record',
  snapshotPath: './snapshots.json',
  
  excludeUrls: [
    'https://analytics.example.com',  // String match
    /\/api\/v\d+\/health/,           // Regex pattern
    'telemetry'                      // Substring match
  ]
})

Configure automatic memory and disk management:

const agent = new SnapshotAgent({
  mode: 'record',
  snapshotPath: './snapshots.json',
  
  // Keep only 1000 snapshots in memory
  maxSnapshots: 1000,
  
  // Automatically save to disk every 30 seconds
  autoFlush: true,
  flushInterval: 30000
})

Handle multiple responses for the same request (similar to nock):

// In record mode, multiple identical requests get recorded as separate responses
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './sequential.json' })

// First call returns response A
await fetch('https://api.example.com/random')

// Second call returns response B  
await fetch('https://api.example.com/random')

await agent.saveSnapshots()

// In playback mode, calls return responses in sequence
const playbackAgent = new SnapshotAgent({ mode: 'playback', snapshotPath: './sequential.json' })

// Returns response A
const first = await fetch('https://api.example.com/random')

// Returns response B
const second = await fetch('https://api.example.com/random')

// Third call repeats the last response (B)
const third = await fetch('https://api.example.com/random')
// Load existing snapshots
await agent.loadSnapshots('./old-snapshots.json')

// Get snapshot data
const recorder = agent.getRecorder()
const snapshots = recorder.getSnapshots()

// Modify or filter snapshots
const filteredSnapshots = snapshots.filter(s => 
  !s.request.url.includes('deprecated')
)

// Replace all snapshots
agent.replaceSnapshots(filteredSnapshots.map((snapshot, index) => ({
  hash: `new-hash-${index}`,
  snapshot
})))

// Save updated snapshots
await agent.saveSnapshots('./updated-snapshots.json')
agent.loadSnapshots(filePath?): void

Loads snapshots from a file.

  • filePath String (optional) - Path to load snapshots from. Uses constructor snapshotPath if not provided.

Promise<void>

agent.getRecorder(): void

Gets the underlying SnapshotRecorder instance.

SnapshotRecorder

const recorder = agent.getRecorder()
console.log(`Recorded ${recorder.size()} interactions`)
agent.getMode(): void

Gets the current snapshot mode.

String - The current mode ('record', 'playback', or 'update')

agent.clearSnapshots(): void

Clears all recorded snapshots from memory.

// Record mode
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './get-snapshots.json' })
setGlobalDispatcher(agent)

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

await agent.saveSnapshots()
// Record mode
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './post-snapshots.json' })
setGlobalDispatcher(agent)

const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'Test Post', body: 'Content' })
})

await agent.saveSnapshots()

SnapshotAgent works with all undici APIs, not just fetch:

import { SnapshotAgent, request, setGlobalDispatcher } from 'undici'

const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './request-snapshots.json' })
setGlobalDispatcher(agent)

const { statusCode, headers, body } = await request('https://api.example.com/data')
const data = await body.json()

await agent.saveSnapshots()
import { test } from 'node:test'
import { SnapshotAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'

test('API integration test', async (t) => {
  const originalDispatcher = getGlobalDispatcher()
  
  const agent = new SnapshotAgent({
    mode: 'playback',
    snapshotPath: './test/snapshots/api-test.json'
  })
  setGlobalDispatcher(agent)
  
  t.after(() => setGlobalDispatcher(originalDispatcher))
  
  // This will use recorded data
  const response = await fetch('https://api.example.com/users')
  const users = await response.json()
  
  assert(Array.isArray(users))
  assert(users.length > 0)
})
const mode = process.env.SNAPSHOT_MODE || 'playback'

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

// Run with: SNAPSHOT_MODE=record npm test (to record)
// Run with: npm test (to playback)
function createSnapshotAgent(testName, mode = 'playback') {
  return new SnapshotAgent({
    mode,
    snapshotPath: `./test/snapshots/${testName}.json`
  })
}

test('user API test', async (t) => {
  const agent = createSnapshotAgent('user-api')
  setGlobalDispatcher(agent)
  
  // Test implementation...
})

Snapshots are stored as JSON with the following structure:

[
  {
    "hash": "dGVzdC1oYXNo...",
    "snapshot": {
      "request": {
        "method": "GET",
        "url": "https://api.example.com/users",
        "headers": {
          "authorization": "Bearer token"
        },
        "body": undefined
      },
      "response": {
        "statusCode": 200,
        "headers": {
          "content-type": "application/json"
        },
        "body": "eyJkYXRhIjoidGVzdCJ9", // base64 encoded
        "trailers": {}
      },
      "timestamp": "2024-01-01T00:00:00.000Z"
    }
  }
]

By default, SnapshotAgent records all headers and request/response data. For production use, always exclude sensitive information:

const agent = new SnapshotAgent({
  mode: 'record',
  snapshotPath: './snapshots.json',
  
  // Exclude sensitive headers from snapshots
  excludeHeaders: [
    'authorization',
    'x-api-key', 
    'cookie',
    'set-cookie',
    'x-auth-token',
    'x-csrf-token'
  ],
  
  // Filter out requests with sensitive data
  shouldRecord: (requestOpts) => {
    const url = new URL(requestOpts.path, requestOpts.origin)
    
    // Don't record authentication endpoints
    if (url.pathname.includes('/auth/') || url.pathname.includes('/login')) {
      return false
    }
    
    // Don't record if request contains sensitive body data
    if (requestOpts.body && typeof requestOpts.body === 'string') {
      const body = requestOpts.body.toLowerCase()
      if (body.includes('password') || body.includes('secret')) {
        return false
      }
    }
    
    return true
  }
})

Important: Snapshot files may contain sensitive data. Handle them securely:

  • ✅ Add snapshot files to .gitignore if they contain real API data
  • ✅ Use environment-specific snapshots (dev/staging/prod)
  • ✅ Regularly review snapshot contents for sensitive information
  • ✅ Use the excludeHeaders option for production snapshots
  • ❌ Never commit snapshots with real authentication tokens
  • ❌ Don't share snapshot files containing personal data
# Exclude snapshots with real data
/test/snapshots/production-*.json
/test/snapshots/*-real-data.json

# Include sanitized test snapshots
!/test/snapshots/mock-*.json
try {
  const response = await fetch('https://api.example.com/nonexistent')
} catch (error) {
  if (error.message.includes('No snapshot found')) {
    // Handle missing snapshot
    console.log('Snapshot not found for this request')
  }
}
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './snapshots.json' })

try {
  const response = await fetch('https://nonexistent-api.example.com/data')
} catch (error) {
  // Network errors are not recorded as snapshots
  console.log('Network error:', error.message)
}
// Use descriptive snapshot file names
const agent = new SnapshotAgent({
  mode: 'playback',
  snapshotPath: `./test/snapshots/${testSuiteName}-${testName}.json`
})

Add snapshot files to version control to ensure consistent test behavior across environments:

# Include snapshots in version control
!/test/snapshots/*.json
test('API test', async (t) => {
  const agent = new SnapshotAgent({
    mode: 'playback',
    snapshotPath: './test/snapshots/temp-test.json'
  })
  
  // Clean up after test
  t.after(() => {
    agent.clearSnapshots()
  })
})
test('validate snapshot contents', async (t) => {
  const agent = new SnapshotAgent({
    mode: 'playback',
    snapshotPath: './test/snapshots/validation.json'
  })
  
  const recorder = agent.getRecorder()
  const snapshots = recorder.getSnapshots()
  
  // Validate snapshot structure
  assert(snapshots.length > 0, 'Should have recorded snapshots')
  assert(snapshots[0].request.url.startsWith('https://'), 'Should use HTTPS')
})

Manual MockAgent:

const mockAgent = new MockAgent()
const mockPool = mockAgent.get('https://api.example.com')

mockPool.intercept({
  path: '/users',
  method: 'GET'
}).reply(200, [
  { id: 1, name: 'User 1' },
  { id: 2, name: 'User 2' }
])

SnapshotAgent:

// Record once
const agent = new SnapshotAgent({ mode: 'record', snapshotPath: './snapshots.json' })
// Real API call gets recorded automatically

// Use in tests
const agent = new SnapshotAgent({ mode: 'playback', snapshotPath: './snapshots.json' })
// Automatically replays recorded response

SnapshotAgent provides similar functionality to nock but is specifically designed for undici:

  • ✅ Works with all undici APIs (request, stream, pipeline, etc.)
  • ✅ Supports undici-specific features (RetryAgent, connection pooling)
  • ✅ Better TypeScript integration
  • ✅ More efficient for high-performance scenarios