jestjs / jest

Delightful JavaScript Testing.
https://jestjs.io
MIT License
44.05k stars 6.44k forks source link

Expose matchers in expect.extend #10329

Open georeith opened 4 years ago

georeith commented 4 years ago

šŸš€ 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 commented 3 years ago

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 commented 3 years ago

@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 commented 3 years ago

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

mmmmmrob commented 2 years ago

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 commented 2 years ago

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

pke commented 1 year ago

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

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

mrazauskas commented 1 year ago

See this PR: https://github.com/facebook/jest/pull/13375

pke commented 1 year ago

This should be merged then ;)

pke commented 1 year ago

@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 commented 1 year ago

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 commented 1 year ago

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 commented 8 months ago

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 :tada: . Thanks @davispuh !