Hone logo
Hone
Problems

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:

  1. 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.
  2. Matcher Function Signature: Each custom matcher function should accept at least two arguments:
    • received: The value being asserted against (e.g., expect(5).toBePositive(), received would be 5).
    • expected (optional): If the matcher expects an argument (e.g., expect(5).toBeGreaterThan(3), expected would be 3).
  3. 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: false and a message property that provides a descriptive error message.
  4. Returned expect Function: The expect function returned by createCustomExpect should behave as follows:
    • It accepts a received value.
    • It returns an object with a not property.
    • The not property 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 received value 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 message provided by the matcher.
  5. Handling not: The not property should invert the pass boolean 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 not for 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 createCustomExpect function should return a function that adheres to a simplified Jest expect interface.
  • 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 message property 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 not property 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 received value and any arguments passed to the matchers are of the expected types. Error handling for incorrect argument types is not required.
Loading editor...
typescript