Skip to content

Expose matchers in expect.extend #10329

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
georeith opened this issue Jul 28, 2020 · 15 comments
Closed

Expose matchers in expect.extend #10329

georeith opened this issue Jul 28, 2020 · 15 comments

Comments

@georeith
Copy link

georeith commented Jul 28, 2020

🚀 Feature Proposal

Expose existing matchers inside expect.extend.

Motivation

Sometimes you want the existing functionality of a matcher but you want to it to transform the input before doing so, for instance, to ignore some specific keys of an object.

Writing a custom matcher is extremely verbose and requires importing additional packages to maintain the same quality of the core matchers (diff in messages).

For example, if I want a matcher that performs toEqual on two objects but ignores a single property on those objects:

expect.extend({
  toEqualDesign(recieved, expected, extraMatchers = []) {
    const recievedDesign = { ...recieved, change: null };
    const expectedDesign = { ...expected, change: null };
    const pass = this.equals(recievedDesign, expectedDesign, extraMatchers);

    // Duplicated from jest.
    // https://github.com/facebook/jest/blob/f3dab7/packages/expect
    // /src/matchers.ts#L538-L569
    /* eslint-disable */
    const matcherName = 'toEqualDesign';
    const options = {
      comment: 'design equality',
      isNot: this.isNot,
      promise: this.promise,
    };
    const message = pass
      ? () =>
          matcherHint(matcherName, undefined, undefined, options) +
          '\n\n' +
          `Expected: ${printExpected(expectedDesign)}\n` +
          `Received: ${printReceived(recievedDesign)}`
      : () => {
          const difference = diff(expectedDesign, recievedDesign, {
            expand: this.expand,
          });

          return (
            matcherHint(matcherName, undefined, undefined, options) +
            '\n\n' +
            (difference && difference.includes('- Expect')
              ? `Difference:\n\n${difference}`
              : `Expected: ${printExpected(expectedDesign)}\n` +
                `Received: ${printReceived(recievedDesign)}`)
          );
        };

    return {
      actual: recievedDesign,
      expected: expectedDesign,
      message,
      name: matcherName,
      pass,
    };
  },
});

Example

return expect.extend({
  toEqualDesign(recieved, expected, ...args) {
    const recievedDesign = { ...recieved, change: null };
    const expectedDesign = { ...expected, change: null };
    return {
      ...this.matchers.toEqual(recievedDesign, expectedDesign, ...args)
      name: 'toEqualDesign',
    };
  },
});

and then:

expect(a).toEqualDesign(b);
expect(a).not.toEqualDesign(b);

Pitch

I am aware this has been asked for before:

The response was to use expect.extend and I do not think it considers these cases where using expect.extend as it stands is not only massively inconvenient upfront for such a simple comparison but creates longer term debt having to maintain the matcher, whereas leveraging the return value of a core matcher allows your matcher to benefit from the continued maintenance of it in the jest core, e.g., if it gets improved messages or the already very verbose matcher return API changes.

This proposal is to enable the ability to write matchers that don't want to introduce new matching behaviour but want to transform their inputs before matching.

Other alternatives include:

expectToEqualDesign(a, b) {
   expect({ ...a, change: null }).toEqual({ ...b, change: null });
}

You then have to handle not yourself by either making separate functions or flagging it:

expectToEqualDesign(a, b, { not: false } = {}) {
   let expectation = expect({ ...a, change: null });
   if (not) {
       expectation = expectation.not;
   }
   expectation.toEqual({ ...b, change: null });
}

Which will work, but now requires you to know an entirely different syntax because of a slight difference to the matcher.

@stephenh
Copy link

I ended up here from #2547, and @SimenB you'd asked for use cases in that one (...admittedly ~3 years ago :-D), but similar to @georeith I want to make a custom matcher that a) accepts args, b) does some pre-processing, and then c) hands off to an existing matcher, in my case toMatchObject to leverage it's great out-of-the-box formatting/diffing/etc capabilities.

Basically, in our project, the actual instance that is passed to my expect(actual).toMatchObject({ ... }) has ugly implementation details that I want to clean up (almost like a .toJSON to get it to be "just data") for the toMatchObject.

In my case I'm using a require hack for now:

export async function toMatchEntity<T>(actual: Entity, expected: MatchedEntity<T>): Promise<CustomMatcherResult> {
  // Clean up `actual` to be "just data"
  const copy = ...project specific stuff...

  // Blatantly grab `toMatchObject` from the guts of expect
  const { getMatchers } = require("expect/build/jestMatchersObject");

  // Now use `toMatchObject` but with our "just data" version of `actual`
  return getMatchers().toMatchObject.call(this, copy, expected);
}

With @georeith 's proposal, the require hack would go away and this could become:

return expect.extend({
  toMatchEntity(actual, expected) {
    const copy = ...same clean up...;
    return this.matchers.toMatchObject(copy, expected);
  },
});

@bpinto
Copy link

bpinto commented Apr 29, 2021

@stephenh This works great, I have been using a similar code for some time now and it's 💯 . However recently I have tried to do the same with expect.objectContaining have you had any success doing the same with these asymmetric matchers?

@stephenh
Copy link

@bpinto hm, no, I haven't tried to re-use objectContaining yet, so I'm not sure how/if it would be different.

@mmmmmrob
Copy link

Another vote for the core matchers to be exposed for use within custom matchers.

In my use case I'd like to write a db-based custom matcher expect(original).toHaveBeenUpdatedTo({…}).

Internally this would be

import { toMatchObject } from 'somewhere'

export const toHaveBeenUpdatedTo = async (original, match) => {
  const updated = await getUpdatedFromDatabase(original)
  return toMatchObject(updated, match)
}

@pke
Copy link

pke commented Apr 13, 2022

that would be extremely helpful to be able to re-use existing matchers in custom matchers.

@pke
Copy link

pke commented Jan 30, 2023

This no longer works, and breaks with the error message:

Cannot find module 'expect/build/jestMatchersObject' from '__tests__/extend-expect.ts'

@mrazauskas
Copy link
Contributor

See this PR: #13375

@pke
Copy link

pke commented Jan 31, 2023

This should be merged then ;)

@pke
Copy link

pke commented Jan 31, 2023

@mrazauskas until the PR is merged, I'd like to understand why the import does not work anymore. The file is there, why can't it be resolved?

@mrazauskas
Copy link
Contributor

Perhaps newer version of Node is taking into account exports while resolving paths?

https://github.com/facebook/jest/blob/836157f4807893bb23a4758a60998fbd61cb184c/packages/expect/package.json#L12-L20

@davispuh
Copy link

davispuh commented Jun 2, 2023

I also need this, I found these hacks to be working

const { matchers } = globalThis[Symbol.for('$$jest-matchers-object')];

// or
import matchers from 'expect/build/matchers';

// or
const matchers = require('expect/build/matchers').default;

@stephenh
Copy link

Ah wow, I'm trying out Jest v30.0.0-alpha.2 and the import from expect/build/matchers doesn't work anymore, but @davispuh 's globalThis[$$jest-matchers-object] does work! In both Jest v29 and Jest v30 🎉 . Thanks @davispuh !

Copy link

This issue is stale because it has been open for 1 year with no activity. Remove stale label or comment or this will be closed in 30 days.

@github-actions github-actions bot added the Stale label Dec 26, 2024
Copy link

This issue was closed because it has been stalled for 30 days with no activity. Please open a new issue if the issue is still relevant, linking to this one.

@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jan 25, 2025
Copy link

This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.
Please note this issue tracker is not a help forum. We recommend using StackOverflow or our discord channel for questions.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 25, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

7 participants