Snapshot tests in Jest and Vitest are powerful tools for detecting unexpected changes in your code's output. However, they easily break when dealing with dynamic values like generated IDs or timestamps that change with each test run. While mocking these values is possible, it can lead to unintended side effects.
Consider this user object which could be returned from an API call or database query:
const user = {
id: crypto.randomUUID(),
name: "John Doe",
createdAt: new Date().toISOString()
};
Every time you run your tests, the id
and createdAt
values will be different, causing your snapshots to fail.
Basic Implementation
Here's how to create a custom serializer that replaces dynamic values with consistent placeholders:
const property = 'id';
const placeholder = '[ID]';
expect.addSnapshotSerializer({
test(val) {
return val && typeof val === 'object' && Object.hasOwn(val, property) && val[property] !== placeholder
},
serialize(val, config, indentation, depth, refs, printer) {
return printer(
{
...(val as Record<string, unknown>),
[property]: placeholder,
},
config,
indentation,
depth,
refs,
);
},
});
You can add a custom snapshot serializer with expect.addSnapshotSerializer()
.
It expects an object with two functions:
test()
is used to determine whether this custom serializer should be used. It checks if the value fromexpect(value)
is an object with the property and has not been replaced by the placeholder.serialize()
is only called iftest()
has returned true. It replaces the property with the placeholder and calls theprinter()
function to serialize the value into a JSON-like string.
Tests
Now, when you run your tests, you will see that id
was replaced with the [ID]
placeholder:
interface User {
id: string;
name: string;
createdAt: string;
}
expect.addSnapshotSerializer({ /* ... */ });
test('snapshot', () => {
const user: User = {
id: '123e4567-e89b-12d3-a456-426614174000',
name: 'John Doe',
createdAt: '2024-03-20T12:00:00Z',
};
expect(user).toMatchInlineSnapshot(`
{
"id": "[ID]",
"name": "John Doe",
}
`);
});
Making it Reusable
What if we need to handle multiple dynamic properties? Let's create a reusable solution:
export const replaceProperty = (
property: string,
placeholder: string,
): SnapshotSerializer => {
return {
test(val) {
return val && typeof val === 'object' && Object.hasOwn(val, property) && val[property] !== placeholder
},
serialize(val, config, indentation, depth, refs, printer) {
return printer(
{
...(val as Record<string, unknown>),
[property]: placeholder,
},
config,
indentation,
depth,
refs,
);
},
};
};
In your tests, you can create multiple serializers for different properties:
expect.addSnapshotSerializer(replaceProperty('id', '[ID]'));
expect.addSnapshotSerializer(replaceProperty('createdAt', '[TIMESTAMP]'));
I use these serializers so frequently that I created the npm package snapshot-serializers to make it easier for everyone.
import { replaceProperty, removeProperty } from 'snapshot-serializers';
type User = {
id: string;
name: string;
createdAt: string;
password?: string;
};
// Type-safe property replacement
expect.addSnapshotSerializer(
// TypeScript will only allow "id" | "name" | "createdAt" | "password"
replaceProperty<User>({
property: 'id',
placeholder: '[ID]'
})
);
// Remove properties entirely
expect.addSnapshotSerializer(
removeProperty<User>({
property: 'password'
})
);
// This would cause a TypeScript error:
expect.addSnapshotSerializer(
replaceProperty<User>({
property: 'invalid' // Error: Type '"invalid"' is not assignable...
})
);
It provides a type-safe API to replace or remove properties in your snapshots. You can provide a generic type parameter like removeProperty<User>()
and the function will suggest all possible property names based on the User
type. Any other property will cause a TypeScript error.
Top comments (0)