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
new SnapshotAgent([options])- 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) => stringto normalize the request body before matching (e.g. strip volatile fields like timestamps). Only used whenmatchBodyistrue. - matchQuery
Boolean- Whether to include query parameters in matching. Default:true - normalizeQuery
Function- Optional function(query: URLSearchParams) => stringto normalize query parameters before matching (e.g. strip volatile params like cache-busters). Only used whenmatchQueryistrue. - 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
MockAgentare supported
- mode
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?): voidSaves all recorded snapshots to a file.
- filePath
String(optional) - Path to save snapshots. Uses constructorsnapshotPathif not provided.
Promise<void>
await agent.saveSnapshots('./custom-snapshots.json')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?): voidLoads snapshots from a file.
- filePath
String(optional) - Path to load snapshots from. Uses constructorsnapshotPathif not provided.
Promise<void>
await agent.loadSnapshots('./existing-snapshots.json')agent.getRecorder(): voidGets the underlying SnapshotRecorder instance.
SnapshotRecorder
const recorder = agent.getRecorder()
console.log(`Recorded ${recorder.size()} interactions`)agent.getMode(): voidGets the current snapshot mode.
String - The current mode ('record', 'playback', or 'update')
agent.clearSnapshots(): voidClears all recorded snapshots from memory.
agent.clearSnapshots()// 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
.gitignoreif they contain real API data - ✅ Use environment-specific snapshots (dev/staging/prod)
- ✅ Regularly review snapshot contents for sensitive information
- ✅ Use the
excludeHeadersoption 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-*.jsontry {
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/*.jsontest('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 responseSnapshotAgent 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
- MockAgent - Manual mocking for more control
- MockCallHistory - Inspecting request history
- Testing Best Practices - General testing guidance