Jest’s expect.extend lets you bolt on custom assertions, but most folks don’t realize you can build entirely new assertion types that go beyond simple toBe or toEqual.
Let’s say you’re working with a complex data structure that represents a user profile, and you frequently need to check if it’s "valid" according to your application’s rules. Instead of writing a bunch of expect(user.name).toBeDefined(), expect(user.email).toMatch(...), and expect(user.age).toBeGreaterThanOrEqual(18) every time, you can create a custom matcher.
Here’s a jest.setup.js file that defines a toBeValidUser matcher:
// jest.setup.js
const { expect } = require('@jest/globals');
expect.extend({
toBeValidUser(received) {
const pass = typeof received.id === 'string' &&
received.id.length > 0 &&
typeof received.name === 'string' &&
received.name.length > 0 &&
typeof received.email === 'string' &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(received.email) &&
typeof received.age === 'number' &&
received.age >= 18;
if (pass) {
return {
message: () => `expected ${this.utils.printReceived(received)} not to be a valid user`,
pass: true,
};
} else {
// Detailed failure messages are key for custom matchers
const failures = [];
if (typeof received.id !== 'string' || received.id.length === 0) failures.push('id is missing or empty');
if (typeof received.name !== 'string' || received.name.length === 0) failures.push('name is missing or empty');
if (typeof received.email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(received.email)) failures.push('email is invalid');
if (typeof received.age !== 'number' || received.age < 18) failures.push('age is less than 18');
return {
message: () => `expected ${this.utils.printReceived(received)} to be a valid user, but failed because: ${failures.join(', ')}`,
pass: false,
};
}
},
});
And here’s how you’d use it in a test file:
// user.test.js
describe('User validation', () => {
it('should validate a user object', () => {
const validUser = {
id: 'user-123',
name: 'Alice Smith',
email: 'alice.smith@example.com',
age: 30,
};
expect(validUser).toBeValidUser();
});
it('should fail for an invalid user', () => {
const invalidUser = {
id: 'user-456',
name: '', // Empty name
email: 'bob@example', // Invalid email
age: 17, // Too young
};
expect(invalidUser).not.toBeValidUser();
});
});
To make Jest aware of this setup, you’d add "setupFilesAfterEnv": ["<rootDir>/jest.setup.js"] to your jest.config.js.
The toBeValidUser matcher takes the received value (the thing you’re calling expect on). It returns an object with a pass boolean and a message function. The message function is crucial for providing clear, actionable feedback when the assertion fails. Jest’s this.utils.printReceived() helps format the received value nicely.
The real power comes when you realize you can define asynchronous matchers too. If your validation involved an API call to a user service, you’d return a Promise from your matcher function.
expect.extend({
async toHaveUserInDatabase(userId) {
const user = await userService.findUserById(userId);
const pass = user !== null;
// ... return message and pass
}
});
This lets you integrate complex, multi-step validation directly into your test assertions without cluttering your test files with await calls and conditional checks.
When defining custom matchers, it’s tempting to just return true or false for pass. However, the real magic is in crafting informative failure messages. Jest provides access to this.utils within the matcher function, which offers helpers like printReceived, printExpected, and diff to generate human-readable output that pinpoints exactly why an assertion failed, rather than just that it did fail. This is especially useful for complex data structures or when comparing objects.
Most people stop at simple equality or type checks. The real counter-intuitive aspect is that you can encode entire business logic validation flows directly into your assertions, making tests read more like the requirements they are testing. This isn’t just about reducing boilerplate; it’s about raising the semantic level of your tests.
The next step is to think about creating "complex" matchers that don’t just return a boolean, but can also return data or trigger side effects based on the assertion’s outcome.