How I Stopped My Jest Snapshots From Churning
A serializer that strips dates, ids, and request hashes out of snapshot JSON so a fresh git clone does not nuke the snapshot diff. Drop into `snapshotSerializers` and forget.
By @marcusreddy
December 14, 2025
·
Updated May 20, 2026
368 views
12
4.2 (11)
// Walk a JS value, replace anything that looks volatile with a stable token.
// Stage 1: pure function, no Jest dependency. You can run this on any object.
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HEX_HASH = /^[0-9a-f]{32,}$/i;
const REDACT_KEYS = new Set(['createdAt', 'updatedAt', 'requestId', 'traceId', 'sessionId']);
function redact(value, parentKey = '') {
if (REDACT_KEYS.has(parentKey)) return `<${parentKey}>`;
if (typeof value === 'string') {
if (ISO_DATE.test(value)) return '<ISO_DATE>';
if (UUID.test(value)) return '<UUID>';
if (HEX_HASH.test(value)) return '<HEX>';
return value;
}
if (Array.isArray(value)) return value.map((v) => redact(v));
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) out[k] = redact(v, k);
return out;
}
return value;
}
const sample = {
id: 'a3f1b8d2-4c7e-4f3a-9b81-1234567890ab',
user: 'alice',
createdAt: '2024-08-22T14:03:11.521Z',
requestId: 'req_8f0e',
payload: {
traceId: '7c1ad9e0',
contentHash: '7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730',
},
};
console.log(JSON.stringify(redact(sample), null, 2));The win is in two layers: a key-name denylist for fields you know are volatile (createdAt, requestId, traceId) and a value-shape sweep for things that look like dates, uuids, or hashes regardless of where they appear. I have learned to keep them separate because the shape sweep catches surprises: an upstream API adds a metadata.runId: '7c1ad9...' and your snapshot does not break. The output uses angle-bracket tokens (<ISO_DATE>) rather than dropping the field, so a missing field still fails the snapshot but a churning timestamp does not.
// Stage 2: the Jest plugin shape.
// In a real project you would put this file path under jest.config.js#snapshotSerializers.
// Here we simulate the test() / serialize() contract Jest expects.
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const REDACT_KEYS = new Set(['createdAt', 'updatedAt', 'requestId']);
function redact(value, parentKey = '') {
if (REDACT_KEYS.has(parentKey)) return `<${parentKey}>`;
if (typeof value === 'string') {
if (ISO_DATE.test(value)) return '<ISO_DATE>';
if (UUID.test(value)) return '<UUID>';
return value;
}
if (Array.isArray(value)) return value.map((v) => redact(v));
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) out[k] = redact(v, k);
return out;
}
return value;
}
// Jest expects: `module.exports = { test, serialize }`
const snapshotSerializer = {
// Apply to plain objects and arrays only; let other serializers handle React, etc.
test(value) {
return value !== null && (Array.isArray(value) || (typeof value === 'object' && value.constructor === Object));
},
serialize(value, _config, indentation, depth, _refs, printer) {
const cleaned = redact(value);
// Defer to Jest's default printer for indentation. Here we approximate.
return JSON.stringify(cleaned, null, 2)
.split('\n')
.map((line, i) => (i === 0 ? line : indentation + line))
.join('\n');
},
};
// Simulate Jest calling the serializer.
function simulateSnapshot(value) {
if (snapshotSerializer.test(value)) {
return snapshotSerializer.serialize(value, null, ' ', 0, null, null);
}
return JSON.stringify(value);
}
const response = {
user: { id: 'a3f1b8d2-4c7e-4f3a-9b81-1234567890ab', name: 'Alice' },
requestId: 'req_8f0e',
createdAt: '2024-08-22T14:03:11.521Z',
};
console.log(simulateSnapshot(response));Jest's serializer contract is two functions: test(value) returns true when this serializer should handle the value, and serialize(value, ...) returns the string. Narrowing test to plain objects and arrays is essential: if you return true for everything, you steal serialization from Jest's React plugin and your <App /> snapshots turn into JSON garbage. Deferring to Jest's printer argument is the right move in production; the example uses JSON.stringify because we are simulating outside a real test runner. Once registered, every expect(x).toMatchSnapshot() automatically gets the redaction.
// Stage 3: per-test overrides without re-registering the serializer.
// Use a thread-local config object that the serializer reads.
const defaults = {
keys: new Set(['createdAt', 'updatedAt', 'requestId', 'traceId']),
patterns: [
{ match: /^\d{4}-\d{2}-\d{2}T/, token: '<ISO_DATE>' },
{ match: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, token: '<UUID>' },
],
};
let currentConfig = defaults;
function withRedaction(overrides, fn) {
const previous = currentConfig;
currentConfig = {
keys: new Set([...previous.keys, ...(overrides.keys || [])]),
patterns: [...previous.patterns, ...(overrides.patterns || [])],
};
try {
return fn();
} finally {
currentConfig = previous;
}
}
function redact(value, parentKey = '') {
if (currentConfig.keys.has(parentKey)) return `<${parentKey}>`;
if (typeof value === 'string') {
for (const { match, token } of currentConfig.patterns) {
if (match.test(value)) return token;
}
return value;
}
if (Array.isArray(value)) return value.map((v) => redact(v));
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) out[k] = redact(v, k);
return out;
}
return value;
}
// Default: stripe-like ids stay visible.
console.log('default:', redact({ stripeId: 'cus_O7yQ123', userId: 'a3f1b8d2-4c7e-4f3a-9b81-1234567890ab' }));
// Inside a specific test we want stripe ids redacted too.
withRedaction({
patterns: [{ match: /^(?:cus|sub|pi|in)_[A-Za-z0-9]+$/, token: '<STRIPE_ID>' }],
keys: ['internalRef'],
}, () => {
console.log('scoped:', redact({
stripeId: 'cus_O7yQ123',
internalRef: 'whatever',
userId: 'a3f1b8d2-4c7e-4f3a-9b81-1234567890ab',
}));
});
// After the helper returns, defaults are restored.
console.log('default again:', redact({ stripeId: 'cus_O7yQ999' }));The override layer is what keeps the serializer useful past the first dozen tests. Most tests want the global rules, a few want extra ones (Stripe ids in a billing test, IPs in a logging test), and you do not want to register a brand-new serializer every time. A try-finally restore is the simplest scope-aware override mechanism and survives Jest's parallel-test model because each worker is its own process. In practice I expose withRedaction from the shared test helper file and call it inside the specific describe block that needs custom rules.
