Implementing Jest's expect.extend in TypeScript
Jest is a popular JavaScript testing framework that provides a powerful assertion library. A key feature of Jest is expect.extend, which allows developers to create custom matchers, extending the assertion capabilities beyond the built-in ones. This challenge asks you to reimplement a simplified version of expect.extend in TypeScript.
Problem Description
Your task is to create a function that mimics the core functionality of Jest's expect.extend. This function should allow users to register new custom matchers that can then be used with a custom expect function.
What needs to be achieved:
You need to implement a function, let's call it createCustomExpect, that takes an object of custom matcher functions as an argument. This createCustomExpect function should return a new expect function. This returned expect function will then be able to use both Jest's built-in matchers and the custom matchers provided during its creation.
Key requirements:
createCustomExpect(customMatchers): This function should accept an object where keys are the names of the custom matchers (e.g.,toBePositive) and values are the matcher functions themselves.- Matcher Function Signature: Each custom matcher function should accept at least two arguments:
received: The value being asserted against (e.g.,expect(5).toBePositive(),receivedwould be5).expected(optional): If the matcher expects an argument (e.g.,expect(5).toBeGreaterThan(3),expectedwould be3).
- Return Value of Matcher Functions:
- If the assertion passes, the matcher function should return an object with
pass: true. - If the assertion fails, the matcher function should return an object with
pass: falseand amessageproperty that provides a descriptive error message.
- If the assertion passes, the matcher function should return an object with
- Returned
expectFunction: Theexpectfunction returned bycreateCustomExpectshould behave as follows:- It accepts a
receivedvalue. - It returns an object with a
notproperty. - The
notproperty should allow for negation of matchers (e.g.,expect(5).not.toBePositive()). - When a matcher is called (e.g.,
expect(5).toBePositive()), it should look for the matcher first among the custom matchers. If not found, it should theoretically delegate to built-in matchers (though for this challenge, we'll focus on only the custom matchers and a placeholder for built-ins). - If a custom matcher is found, it should be invoked with the
receivedvalue and any arguments passed to the matcher. - If the matcher passes, the test continues.
- If the matcher fails, an error should be thrown with the
messageprovided by the matcher.
- It accepts a
- Handling
not: Thenotproperty should invert thepassboolean returned by the matcher. If the matcher returns{ pass: true, message: ... },expect().not.matcher()should throw an error. If the matcher returns{ pass: false, message: ... },expect().not.matcher()should pass. The error message for a negated failure should also be adjusted (e.g., "Expected value NOT to be positive").
Expected behavior:
A user should be able to define custom matchers and then use them with the returned expect function as if they were built-in Jest matchers.
Edge Cases to consider:
- Matchers with no arguments.
- Matchers with one or more arguments.
- The correct handling of
notfor both passing and failing assertions. - What happens if a matcher is called on a received value that doesn't align with its expected type (though the implementation of the matcher itself will handle this).
Examples
Example 1: Basic Custom Matcher
// Assume a simplified Jest-like assertion object structure for demonstration
// In a real Jest scenario, you'd have access to Jest's internal assertion objects.
interface CustomMatcherResult {
pass: boolean;
message: () => string; // Jest matchers return functions for lazy evaluation
}
interface CustomMatcher<T = any> {
(received: T, ...rest: any[]): CustomMatcherResult;
}
interface CustomMatchers {
toBePositive: CustomMatcher<number>;
}
// --- Your implementation of createCustomExpect would go here ---
// Mock Jest's built-in expect for this example to show integration
const mockBuiltInExpect = (received: any) => ({
toBeGreaterThan: (expected: number) => {
if (received > expected) {
return { pass: true, message: () => '' };
} else {
return { pass: false, message: () => `Expected ${received} to be greater than ${expected}` };
}
},
// ... other built-in matchers
});
// Mocking Jest's expect structure that we'll build upon
interface JestExpect<T = any> {
(received: T): JestMatchers<T>;
extend: (matchers: Record<string, CustomMatcher<T>>) => void; // This is what we're implementing conceptually
}
interface JestMatchers<T = any> {
toBePositive(): CustomMatcherResult;
toBeGreaterThan(expected: number): CustomMatcherResult; // Example of a built-in matcher
not: JestMatchers<T>;
}
// --- Function to implement ---
function createCustomExpect(customMatchers: Record<string, CustomMatcher<any>>): JestExpect<any> {
const allMatchers: Record<string, CustomMatcher<any>> = { ...customMatchers }; // Start with custom
const builtInMatchers = mockBuiltInExpect; // In a real Jest env, this would be the actual Jest expect
const expectFn: JestExpect<any> = (received: any) => {
const matchers: JestMatchers<any> = {
not: {} as JestMatchers<any>, // Initialize not object
// Add built-in matchers (simplified for example)
toBeGreaterThan: (expected: number) => {
const result = builtInMatchers(received).toBeGreaterThan(expected);
return {
pass: result.pass,
message: () => result.message(),
};
},
// Dynamically add custom matchers
...Object.keys(allMatchers).reduce((acc, matcherName) => {
acc[matcherName] = (...args: any[]) => {
const matcherFn = allMatchers[matcherName];
const result = matcherFn(received, ...args);
if (!result.pass) {
throw new Error(result.message());
}
return result; // Return the result for chaining or further processing
};
return acc;
}, {}),
};
// Populate the `not` object with inverted matchers
Object.keys(matchers).forEach(matcherName => {
if (matcherName !== 'not' && typeof matchers[matcherName] === 'function') {
matchers.not[matcherName] = (...args: any[]) => {
const matcherFn = allMatchers[matcherName];
const result = matcherFn(received, ...args);
if (result.pass) { // If it passed, the negation fails
throw new Error(`Expected value not to be ${matcherName} with args ${JSON.stringify(args)} (original message: ${result.message()})`);
}
// If it failed, the negation passes
return { pass: true, message: () => '' };
};
}
});
return matchers;
};
// This extend method conceptually is what createCustomExpect achieves
// In our implementation, we pass matchers directly to createCustomExpect
// For adherence to the prompt, we'll imagine this extend is how the user would ADD more later,
// but our primary task is the initial creation.
// We'll keep it simple and assume createCustomExpect registers them once.
// If we were to strictly mimic Jest, `expect.extend` would ADD to a global `expect`.
// Our `createCustomExpect` creates a NEW `expect` instance.
return expectFn;
}
// --- Usage Example ---
const customMatchers: CustomMatchers = {
toBePositive: (received: number): CustomMatcherResult => {
const pass = received > 0;
return {
pass,
message: () => `Expected ${received} to be positive`,
};
},
};
const expectWithCustom = createCustomExpect(customMatchers);
try {
expectWithCustom(5).toBePositive(); // Passes
console.log("Test 1 Passed: expect(5).toBePositive()");
} catch (e: any) {
console.error("Test 1 Failed:", e.message);
}
try {
expectWithCustom(-5).toBePositive(); // Fails
} catch (e: any) {
console.log("Test 2 Passed: expect(-5).toBePositive() failed as expected.", e.message);
}
try {
expectWithCustom(10).toBeGreaterThan(5); // Uses built-in
console.log("Test 3 Passed: expect(10).toBeGreaterThan(5)");
} catch (e: any) {
console.error("Test 3 Failed:", e.message);
}
try {
expectWithCustom(3).not.toBeGreaterThan(5); // Uses built-in with not
console.log("Test 4 Passed: expect(3).not.toBeGreaterThan(5)");
} catch (e: any) {
console.error("Test 4 Failed:", e.message);
}
try {
expectWithCustom(5).not.toBePositive(); // Uses custom with not, should fail
} catch (e: any) {
console.log("Test 5 Passed: expect(5).not.toBePositive() failed as expected.", e.message);
}
try {
expectWithCustom(-5).not.toBePositive(); // Uses custom with not, should pass
console.log("Test 6 Passed: expect(-5).not.toBePositive()");
} catch (e: any) {
console.error("Test 6 Failed:", e.message);
}
Explanation:
The expectWithCustom(5).toBePositive() call invokes the toBePositive matcher registered with createCustomExpect. Since 5 > 0, the matcher returns { pass: true }, and no error is thrown. The expectWithCustom(-5).toBePositive() call fails because -5 is not positive, and the matcher returns { pass: false, message: 'Expected -5 to be positive' }. The expect function then throws an error with this message. The not examples demonstrate how the pass value is inverted.
Example 2: Matcher with Arguments and Negation
// Using the same createCustomExpect function from Example 1
interface CustomMatchers {
toBeGreaterThan: CustomMatcher<number>;
}
const customMatchersWithArgs: CustomMatchers = {
toBeGreaterThan: (received: number, expected: number): CustomMatcherResult => {
const pass = received > expected;
return {
pass,
message: () => `Expected ${received} to be greater than ${expected}`,
};
},
};
const expectWithArgs = createCustomExpect(customMatchersWithArgs);
try {
expectWithArgs(10).toBeGreaterThan(5); // Passes
console.log("Test 7 Passed: expect(10).toBeGreaterThan(5)");
} catch (e: any) {
console.error("Test 7 Failed:", e.message);
}
try {
expectWithArgs(5).toBeGreaterThan(5); // Fails
} catch (e: any) {
console.log("Test 8 Passed: expect(5).toBeGreaterThan(5) failed as expected.", e.message);
}
try {
expectWithArgs(5).not.toBeGreaterThan(5); // Passes with not
console.log("Test 9 Passed: expect(5).not.toBeGreaterThan(5)");
} catch (e: any) {
console.error("Test 9 Failed:", e.message);
}
try {
expectWithArgs(10).not.toBeGreaterThan(5); // Fails with not
} catch (e: any) {
console.log("Test 10 Passed: expect(10).not.toBeGreaterThan(5) failed as expected.", e.message);
}
Explanation:
This example shows a custom matcher toBeGreaterThan that accepts an expected argument. The behavior mirrors the built-in Jest toBeGreaterThan matcher. The not cases correctly invert the outcome.
Constraints
- The implementation should be in TypeScript.
- Your
createCustomExpectfunction should return a function that adheres to a simplified Jestexpectinterface. - Focus on implementing the core logic of adding and invoking custom matchers, including negation. You do not need to replicate Jest's complex internal structures or all its built-in matchers.
- The
messageproperty returned by your matcher functions should be a function that returns a string, mimicking Jest's lazy evaluation of messages.
Notes
- Consider how you will store and retrieve the registered custom matchers.
- Think about how to handle the
notproperty to invert the logic of your matchers. - The goal is to understand the underlying mechanism of extending assertion libraries. You are building a core piece of functionality, not a full Jest replacement.
- For the purpose of this challenge, you can assume that the
receivedvalue and any arguments passed to the matchers are of the expected types. Error handling for incorrect argument types is not required.