Skip to content

Commit 4fd1cb3

Browse files
authored
[Feature] Add support for custom equality testers (#13654)
1 parent 6cd65a1 commit 4fd1cb3

17 files changed

+852
-39
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### Features
44

5+
- `[expect, @jest/expect-utils]` Support custom equality testers ([#13654](https://github.com/facebook/jest/pull/13654))
56
- `[jest-haste-map]` ignore Sapling vcs directories (`.sl/`) ([#13674](https://github.com/facebook/jest/pull/13674))
67
- `[jest-resolve]` Support subpath imports ([#13705](https://github.com/facebook/jest/pull/13705))
78
- `[jest-runtime]` Add `jest.isolateModulesAsync` for scoped module initialization of asynchronous functions ([#13680](https://github.com/facebook/jest/pull/13680))

docs/ExpectAPI.md

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,9 +300,9 @@ A string allowing you to display a clear and correct matcher hint:
300300
- `'resolves'` if matcher was called with the promise `.resolves` modifier
301301
- `''` if matcher was not called with a promise modifier
302302

303-
#### `this.equals(a, b)`
303+
#### `this.equals(a, b, customTesters?)`
304304

305-
This is a deep-equality function that will return `true` if two objects have the same values (recursively).
305+
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).
306306

307307
#### `this.expand`
308308

@@ -366,6 +366,10 @@ This will print something like this:
366366

367367
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.
368368

369+
#### `this.customTesters`
370+
371+
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.
372+
369373
#### Custom snapshot matchers
370374

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

502+
### `expect.addEqualityTesters(testers)`
503+
504+
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:
505+
506+
```js title="Volume.js"
507+
// For simplicity in this example, we'll just support the units 'L' and 'mL'
508+
export class Volume {
509+
constructor(amount, unit) {
510+
this.amount = amount;
511+
this.unit = unit;
512+
}
513+
514+
toString() {
515+
return `[Volume ${this.amount}${this.unit}]`;
516+
}
517+
518+
equals(other) {
519+
if (this.unit === other.unit) {
520+
return this.amount === other.amount;
521+
} else if (this.unit === 'L' && other.unit === 'mL') {
522+
return this.amount * 1000 === other.unit;
523+
} else {
524+
return this.amount === other.unit * 1000;
525+
}
526+
}
527+
}
528+
```
529+
530+
```js title="areVolumesEqual.js"
531+
import {expect} from '@jest/globals';
532+
import {Volume} from './Volume.js';
533+
534+
function areVolumesEqual(a, b) {
535+
const isAVolume = a instanceof Volume;
536+
const isBVolume = b instanceof Volume;
537+
538+
if (isAVolume && isBVolume) {
539+
return a.equals(b);
540+
} else if (isAVolume !== isBVolume) {
541+
return false;
542+
} else {
543+
return undefined;
544+
}
545+
}
546+
547+
expect.addEqualityTesters([areVolumesEqual]);
548+
```
549+
550+
```js title="__tests__/Volume.test.js"
551+
import {expect, test} from '@jest/globals';
552+
import {Volume} from '../Volume.js';
553+
import '../areVolumesEqual.js';
554+
555+
test('are equal with different units', () => {
556+
expect(new Volume(1, 'L')).toEqual(new Volume(1000, 'mL'));
557+
});
558+
```
559+
560+
```ts title="Volume.ts"
561+
// For simplicity in this example, we'll just support the units 'L' and 'mL'
562+
export class Volume {
563+
public amount: number;
564+
public unit: 'L' | 'mL';
565+
566+
constructor(amount: number, unit: 'L' | 'mL') {
567+
this.amount = amount;
568+
this.unit = unit;
569+
}
570+
571+
toString(): string {
572+
return `[Volume ${this.amount}${this.unit}]`;
573+
}
574+
575+
equals(other: Volume): boolean {
576+
if (this.unit === other.unit) {
577+
return this.amount === other.amount;
578+
} else if (this.unit === 'L' && other.unit === 'mL') {
579+
return this.amount * 1000 === other.amount;
580+
} else {
581+
return this.amount === other.amount * 1000;
582+
}
583+
}
584+
}
585+
```
586+
587+
```ts title="areVolumesEqual.ts"
588+
import {expect} from '@jest/globals';
589+
import {Volume} from './Volume.js';
590+
591+
function areVolumesEqual(a: unknown, b: unknown): boolean | undefined {
592+
const isAVolume = a instanceof Volume;
593+
const isBVolume = b instanceof Volume;
594+
595+
if (isAVolume && isBVolume) {
596+
return a.equals(b);
597+
} else if (isAVolume !== isBVolume) {
598+
return false;
599+
} else {
600+
return undefined;
601+
}
602+
}
603+
604+
expect.addEqualityTesters([areVolumesEqual]);
605+
```
606+
607+
```ts title="__tests__/Volume.test.ts"
608+
import {expect, test} from '@jest/globals';
609+
import {Volume} from '../Volume.js';
610+
import '../areVolumesEqual.js';
611+
612+
test('are equal with different units', () => {
613+
expect(new Volume(1, 'L')).toEqual(new Volume(1000, 'mL'));
614+
});
615+
```
616+
617+
#### Custom equality testers API
618+
619+
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).
620+
621+
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).
622+
623+
These helper functions and properties can be found on `this` inside a custom tester:
624+
625+
#### `this.equals(a, b, customTesters?)`
626+
627+
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. If you use this function, pass through the custom testers your tester is given so further equality checks `equals` applies can also use custom testers the test author may have configured. See the example in the [Recursive custom equality testers][#recursivecustomequalitytesters] section for more details.
628+
629+
#### Matchers vs Testers
630+
631+
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.
632+
633+
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`](#expectextendmatchers) 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.
634+
635+
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.
636+
637+
#### Recursive custom equality testers
638+
639+
If you custom equality testers is testing objects with properties you'd like to do deep equality with, you should use the `this.equals` helper available to equality testers. 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.
640+
641+
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 given to it so the `Author`s custom equality tester is applied:
642+
643+
```js title="customEqualityTesters.js"
644+
function areAuthorEqual(a, b) {
645+
const isAAuthor = a instanceof Author;
646+
const isBAuthor = b instanceof Author;
647+
648+
if (isAAuthor && isBAuthor) {
649+
// Authors are equal if they have the same name
650+
return a.name === b.name;
651+
} else if (isAAuthor !== isBAuthor) {
652+
return false;
653+
} else {
654+
return undefined;
655+
}
656+
}
657+
658+
function areBooksEqual(a, b, customTesters) {
659+
const isABook = a instanceof Book;
660+
const isBBook = b instanceof Book;
661+
662+
if (isABook && isBBook) {
663+
// Books are the same if they have the same name and author array. We need
664+
// to pass customTesters to equals here so the Author custom tester will be
665+
// used when comparing Authors
666+
return (
667+
a.name === b.name && this.equals(a.authors, b.authors, customTesters)
668+
);
669+
} else if (isABook !== isBBook) {
670+
return false;
671+
} else {
672+
return undefined;
673+
}
674+
}
675+
676+
expect.addEqualityTesters([areAuthorsEqual, areBooksEqual]);
677+
```
678+
679+
:::note
680+
681+
Remember to define your equality testers as regular functions and **not** arrow functions in order to access the tester context helpers (e.g. `this.equals`).
682+
683+
:::
684+
498685
### `expect.anything()`
499686

500687
`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:

packages/expect-utils/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ export {equals, isA} from './jasmineUtils';
1010
export type {EqualsFunction} from './jasmineUtils';
1111
export * from './utils';
1212

13-
export type {Tester} from './types';
13+
export type {Tester, TesterContext} from './types';

packages/expect-utils/src/jasmineUtils.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
2222
2323
*/
2424

25-
import type {Tester} from './types';
25+
import type {Tester, TesterContext} from './types';
2626

2727
export type EqualsFunction = (
2828
a: unknown,
@@ -75,8 +75,14 @@ function eq(
7575
return asymmetricResult;
7676
}
7777

78+
const testerContext: TesterContext = {equals};
7879
for (let i = 0; i < customTesters.length; i++) {
79-
const customTesterResult = customTesters[i](a, b);
80+
const customTesterResult = customTesters[i].call(
81+
testerContext,
82+
a,
83+
b,
84+
customTesters,
85+
);
8086
if (customTesterResult !== undefined) {
8187
return customTesterResult;
8288
}

packages/expect-utils/src/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,15 @@
66
*
77
*/
88

9-
export type Tester = (a: any, b: any) => boolean | undefined;
9+
import type {EqualsFunction} from './jasmineUtils';
10+
11+
export type Tester = (
12+
this: TesterContext,
13+
a: any,
14+
b: any,
15+
customTesters: Array<Tester>,
16+
) => boolean | undefined;
17+
18+
export interface TesterContext {
19+
equals: EqualsFunction;
20+
}

0 commit comments

Comments
 (0)