Skip to content
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

[Feature] Add support for custom equality testers #13654

Merged
merged 30 commits into from
Jan 3, 2023
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
63d7186
Add customEqualityTesters support to toEqual
andrewiggins Dec 1, 2022
8f1daca
Add support for custom testers in iterableEquality
andrewiggins Dec 1, 2022
45243cd
Add customTester support to toContainEqual and toHaveProperty
andrewiggins Dec 1, 2022
cb73a20
Add customTester support toStrictEqual
andrewiggins Dec 1, 2022
ce6aca4
Add customTester support to toMatchObject
andrewiggins Dec 1, 2022
b5684ce
Add customTesters to asymmetric matchers
andrewiggins Dec 1, 2022
2fa1336
Add customTesters to spy matchers
andrewiggins Dec 1, 2022
e414bcc
Add test for custom matcher
andrewiggins Dec 1, 2022
944934f
Clean up new tests a bit
andrewiggins Dec 1, 2022
8a8bc17
Add support for customTesters to matcher recommendations in errors
andrewiggins Dec 1, 2022
1ee862f
Give custom testers higher priority over built-in testers
andrewiggins Dec 1, 2022
3961c37
Add custom testers to getObjectSubset
andrewiggins Dec 1, 2022
7bdcded
Add CHANGELOG entry
andrewiggins Dec 1, 2022
144f351
Fix customEqualityTesters TS errors
andrewiggins Dec 1, 2022
1c9b629
Update packages/expect/src/__tests__/customEqualityTesters.test.ts
andrewiggins Dec 2, 2022
9998279
Change API to addEqualityTesters
andrewiggins Dec 16, 2022
9fcf9b5
Get customTesters from matcherContext
andrewiggins Dec 16, 2022
a791dcb
Rename customTesters to filteredCustomTesters
andrewiggins Dec 16, 2022
32d0e38
Add type tests for new API
andrewiggins Dec 16, 2022
05eca9f
Merge branch 'main' into custom-tester-extensions
SimenB Jan 1, 2023
b72796b
Merge branch 'main' into custom-tester-extensions
andrewiggins Jan 1, 2023
c9dc27c
Add docs, pt. 1
andrewiggins Jan 2, 2023
d22295b
Reorganize code to obsolete eslint ignore
andrewiggins Jan 2, 2023
8591181
Convert custom equal testers tests to use Volume object matching docs
andrewiggins Jan 2, 2023
e88fff2
Finish out ExpectAPI docs and add recursive equality tester test
andrewiggins Jan 2, 2023
bed9a1e
reorganize
SimenB Jan 2, 2023
15e509c
link in changelog
SimenB Jan 2, 2023
96de10d
link section
SimenB Jan 2, 2023
dea4949
Expose equals function on tester context
andrewiggins Jan 3, 2023
90afadb
Re-export Tester and TesterContext in expect package
andrewiggins Jan 3, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

### Features

- `[expect, @jest/expect-utils]` Support custom equality testers
- `[jest-haste-map]` ignore Sapling vcs directories (`.sl/`) ([#13674](https://github.com/facebook/jest/pull/13674))
- `[jest-resolve]` Support subpath imports ([#13705](https://github.com/facebook/jest/pull/13705))
- `[jest-runtime]` Add `jest.isolateModulesAsync` for scoped module initialization of asynchronous functions ([#13680](https://github.com/facebook/jest/pull/13680))
Expand Down
179 changes: 177 additions & 2 deletions docs/ExpectAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,9 @@ A string allowing you to display a clear and correct matcher hint:
- `'resolves'` if matcher was called with the promise `.resolves` modifier
- `''` if matcher was not called with a promise modifier

#### `this.equals(a, b)`
#### `this.equals(a, b, customTesters?)`

This is a deep-equality function that will return `true` if two objects have the same values (recursively).
This is a deep-equality function that will return `true` if two objects have the same values (recursively). It optionally takes a list of custom equality testers to apply to the deep equality checks (see `this.customTesters` below).

#### `this.expand`

Expand Down Expand Up @@ -366,6 +366,10 @@ This will print something like this:

When an assertion fails, the error message should give as much signal as necessary to the user so they can resolve their issue quickly. You should craft a precise failure message to make sure users of your custom assertions have a good developer experience.

#### `this.customTesters`

If your matcher does a deep equality check using `this.equals`, you may want to pass user provided custom testers to `this.equals`. The custom equality testers that the user has provided using the `addEqualityTesters` API are available on this property. The built-in Jest matchers pass `this.customTesters` (along with other built-in testers) to `this.equals` to do deep equality, and your custom matchers may want to do the same.

#### Custom snapshot matchers

To use snapshot testing inside of your custom matcher you can import `jest-snapshot` and use it from within your matcher.
Expand Down Expand Up @@ -495,6 +499,177 @@ it('transitions as expected', () => {
});
```

### `expect.addEqualityTesters(testers)`

You can use `expect.addEqualityTesters` to add your own methods to test if two objects are equal. For example, let's say you have a class in your code that represents volume and it supports determining if two volumes using different units are equal or not. You may want `toEqual` (and other equality matchers) to use this custom equality method when comparing to Volume classes. You can add a custom equality tester to have `toEqual` detect and apply custom logic when comparing Volume classes:

```js title="Volume.js"
// For simplicity in this example, we'll just support the units 'L' and 'mL'
export class Volume {
constructor(amount, unit) {
this.amount = amount;
this.unit = unit;
}

toString() {
return `[Volume ${this.amount}${this.unit}]`;
}

equals(other) {
if (this.unit === other.unit) {
return this.amount === other.amount;
} else if (this.unit === 'L' && other.unit === 'mL') {
return this.amount * 1000 === other.unit;
} else {
return this.amount === other.unit * 1000;
}
}
}
```

```js title="areVolumesEqual.js"
import {expect} from '@jest/globals';
import {Volume} from './Volume.js';

function areVolumesEqual(a, b) {
const isAVolume = a instanceof Volume;
const isBVolume = b instanceof Volume;

if (isAVolume && isBVolume) {
return a.equals(b);
} else if (isAVolume !== isBVolume) {
return false;
} else {
return undefined;
}
}

expect.addEqualityTesters([areVolumesEqual]);
```

```js title="__tests__/Volume.test.js"
import {expect, test} from '@jest/globals';
import {Volume} from '../Volume.js';
import '../areVolumesEqual.js';

test('are equal with different units', () => {
expect(new Volume(1, 'L')).toEqual(new Volume(1000, 'mL'));
});
```

```ts title="Volume.ts"
// For simplicity in this example, we'll just support the units 'L' and 'mL'
export class Volume {
public amount: number;
public unit: 'L' | 'mL';

constructor(amount: number, unit: 'L' | 'mL') {
this.amount = amount;
this.unit = unit;
}

toString(): string {
return `[Volume ${this.amount}${this.unit}]`;
}

equals(other: Volume): boolean {
if (this.unit === other.unit) {
return this.amount === other.amount;
} else if (this.unit === 'L' && other.unit === 'mL') {
return this.amount * 1000 === other.amount;
} else {
return this.amount === other.amount * 1000;
}
}
}
```

```ts title="areVolumesEqual.ts"
import {expect} from '@jest/globals';
import {Volume} from './Volume.js';

function areVolumesEqual(a: unknown, b: unknown): boolean | undefined {
const isAVolume = a instanceof Volume;
const isBVolume = b instanceof Volume;

if (isAVolume && isBVolume) {
return a.equals(b);
} else if (isAVolume !== isBVolume) {
return false;
} else {
return undefined;
}
}

expect.addEqualityTesters([areVolumesEqual]);
```

```ts title="__tests__/Volume.test.ts"
import {expect, test} from '@jest/globals';
import {Volume} from '../Volume.js';
import '../areVolumesEqual.js';

test('are equal with different units', () => {
expect(new Volume(1, 'L')).toEqual(new Volume(1000, 'mL'));
});
```

#### Custom equality testers API

Custom testers are functions that return either the result (`true` or `false`) of comparing the equality of the two given arguments or `undefined` if tester does not handle the given the objects and wants to delegate equality to other testers (for example, the built in equality testers).

Custom testers are called with 3 arguments: the two objects to compare and the array of custom testers (used for recursive testers, see section below).

#### Matchers vs Testers

Matchers are methods available on `expect`, for example `expect().toEqual()`. `toEqual` is a matcher. A tester is a method used by matchers that do equality checks to determine if objects are the same.

Custom matchers are good to use when you want to provide a custom assertion that test authors can use in their tests. For example, the `toBeWithinRange` example in the `expect.extend` section is a good example of a custom matcher. Sometimes a test author may want to assert two numbers are exactly equal and should use `toBe`. Other times however, a test author may want to allow for some flexibility in their test and `toBeWithinRange` may be a more appropriate assertion.

Custom equality testers are good to use for globally extending Jest matchers to apply custom equality logic for all equality comparisons. Test authors can't turn on custom testers for certain assertions and turn off for others (a custom matcher should be used instead if that behavior is desired). For example, defining how to check if two `Volume` objects are equal for all matchers would be a good custom equality tester.

#### Recursive custom equality testers

If you custom equality testers is testing objects with properties you'd like to do deep equality with, you should use the `equals` helper from the `@jest/expect-utils` package. This `equals` method is the same deep equals method Jest uses internally for all of its deep equality comparisons. Its the method that invokes your custom equality tester. It accepts an array of custom equality testers as a third argument. Custom equality testers are also given an array of custom testers as their third argument. Pass this argument into the third argument of `equals` so that any further equality checks deeper in your object can also take advantage of custom equality testers.

For example, let's say you have a `Book` class that contains an array of `Author` classes and both of these classes have custom testers. The `Book` custom tester would want to do a deep equality check on the array of `Author`s and pass in the custom testers so the `Author`s custom equality tester is applied:

```js title="customEqualityTesters.js"
import {equals} from '@jest/expect-utils';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we ask people to use this.equals instead of importing it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be happy to, but currently customTesters are called without any this context (source from this PR). Custom testers are invoked by equals in the expect-utils package and so don't have access to the matcher context.

One idea to avoid importing encouraging importing equals would be to create an EqualityTesterContext with equals and invoke equality testers with that, similar to what we do for matchers and MatchContext. Should I do that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, that makes sense! Yeah, I think we should have a context with at least equals (maybe only that for now?). That way, if people publish custom equality testers, they don't need to care what version of Jest people are using (beyond 29.4+) when we release 30, 31 etc..

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh nice! Yea, that makes sense. I believe the latest changes I've pushed do this. Let me know if you see anything else missing!

I've also re-exported the Tester and TesterContext types from the expect package. My thinking was that since those types are implicitly exposed through expect.addEqualityTester, explicitly exporting them would make using TypeScript a little easier and better mirror what people can do with expect.extend with the MatcherFunction and MatcherContext types. However, if you'd prefer people import Tester and TesterContext directly from @jest/expect-utils, we can simply revert that commit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good 👍


const areAuthorsEqual = (a, b) => {
const isAAuthor = a instanceof Author;
const isBAuthor = b instanceof Author;

if (isAAuthor && isBAuthor) {
// Authors are equal if they have the same name
return a.name === b.name;
} else if (isAAuthor !== isBAuthor) {
return false;
} else {
return undefined;
}
};

const areBooksEqual = (a, b, customTesters) => {
const isABook = a instanceof Book;
const isBBook = b instanceof Book;

if (isABook && isBBook) {
// Books are the same if they have the same name and author array. We need
// to pass customTesters to equals here so the Author custom tester will be
// used when comparing Authors
return a.name === b.name && equals(a.authors, b.authors, customTesters);
} else if (isABook !== isBBook) {
return false;
} else {
return undefined;
}
};

expect.addEqualityTesters([areAuthorsEqual, areBooksEqual]);
```

### `expect.anything()`

`expect.anything()` matches anything but `null` or `undefined`. You can use it inside `toEqual` or `toBeCalledWith` instead of a literal value. For example, if you want to check that a mock function is called with a non-null argument:
Expand Down
2 changes: 1 addition & 1 deletion packages/expect-utils/src/jasmineUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function eq(
}

for (let i = 0; i < customTesters.length; i++) {
const customTesterResult = customTesters[i](a, b);
const customTesterResult = customTesters[i](a, b, customTesters);
if (customTesterResult !== undefined) {
return customTesterResult;
}
Expand Down
6 changes: 5 additions & 1 deletion packages/expect-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@
*
*/

export type Tester = (a: any, b: any) => boolean | undefined;
export type Tester = (
a: any,
b: any,
customTesters: Array<Tester>,
andrewiggins marked this conversation as resolved.
Show resolved Hide resolved
SimenB marked this conversation as resolved.
Show resolved Hide resolved
) => boolean | undefined;
Loading