diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c0e2298881..2fb895c3062e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[expect, @jest/expect-utils]` Support custom equality testers ([#13654](https://github.com/facebook/jest/pull/13654)) - `[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)) diff --git a/docs/ExpectAPI.md b/docs/ExpectAPI.md index 54d2d16a9d48..4c19635d9f28 100644 --- a/docs/ExpectAPI.md +++ b/docs/ExpectAPI.md @@ -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` @@ -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. @@ -495,6 +499,189 @@ 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). + +These helper functions and properties can be found on `this` inside a custom tester: + +#### `this.equals(a, b, customTesters?)` + +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. + +#### 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`](#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. + +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 `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. + +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: + +```js title="customEqualityTesters.js" +function areAuthorEqual(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; + } +} + +function 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 && this.equals(a.authors, b.authors, customTesters) + ); + } else if (isABook !== isBBook) { + return false; + } else { + return undefined; + } +} + +expect.addEqualityTesters([areAuthorsEqual, areBooksEqual]); +``` + +:::note + +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`). + +::: + ### `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: diff --git a/packages/expect-utils/src/index.ts b/packages/expect-utils/src/index.ts index 6eb5e82d3d91..1cb442e73c51 100644 --- a/packages/expect-utils/src/index.ts +++ b/packages/expect-utils/src/index.ts @@ -10,4 +10,4 @@ export {equals, isA} from './jasmineUtils'; export type {EqualsFunction} from './jasmineUtils'; export * from './utils'; -export type {Tester} from './types'; +export type {Tester, TesterContext} from './types'; diff --git a/packages/expect-utils/src/jasmineUtils.ts b/packages/expect-utils/src/jasmineUtils.ts index 6aec53aa1127..dcaebae99698 100644 --- a/packages/expect-utils/src/jasmineUtils.ts +++ b/packages/expect-utils/src/jasmineUtils.ts @@ -22,7 +22,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type {Tester} from './types'; +import type {Tester, TesterContext} from './types'; export type EqualsFunction = ( a: unknown, @@ -75,8 +75,14 @@ function eq( return asymmetricResult; } + const testerContext: TesterContext = {equals}; for (let i = 0; i < customTesters.length; i++) { - const customTesterResult = customTesters[i](a, b); + const customTesterResult = customTesters[i].call( + testerContext, + a, + b, + customTesters, + ); if (customTesterResult !== undefined) { return customTesterResult; } diff --git a/packages/expect-utils/src/types.ts b/packages/expect-utils/src/types.ts index 361f648f5f56..66c8644d3868 100644 --- a/packages/expect-utils/src/types.ts +++ b/packages/expect-utils/src/types.ts @@ -6,4 +6,15 @@ * */ -export type Tester = (a: any, b: any) => boolean | undefined; +import type {EqualsFunction} from './jasmineUtils'; + +export type Tester = ( + this: TesterContext, + a: any, + b: any, + customTesters: Array, +) => boolean | undefined; + +export interface TesterContext { + equals: EqualsFunction; +} diff --git a/packages/expect-utils/src/utils.ts b/packages/expect-utils/src/utils.ts index 5f11d9c306c2..32a403ba4874 100644 --- a/packages/expect-utils/src/utils.ts +++ b/packages/expect-utils/src/utils.ts @@ -16,6 +16,7 @@ import { isImmutableUnorderedSet, } from './immutableUtils'; import {equals, isA} from './jasmineUtils'; +import type {Tester} from './types'; type GetPath = { hasEndProp?: boolean; @@ -102,6 +103,7 @@ export const getPath = ( export const getObjectSubset = ( object: any, subset: any, + customTesters: Array = [], seenReferences: WeakMap = new WeakMap(), ): any => { /* eslint-enable @typescript-eslint/explicit-module-boundary-types */ @@ -109,13 +111,19 @@ export const getObjectSubset = ( if (Array.isArray(subset) && subset.length === object.length) { // The map method returns correct subclass of subset. return subset.map((sub: any, i: number) => - getObjectSubset(object[i], sub), + getObjectSubset(object[i], sub, customTesters), ); } } else if (object instanceof Date) { return object; } else if (isObject(object) && isObject(subset)) { - if (equals(object, subset, [iterableEquality, subsetEquality])) { + if ( + equals(object, subset, [ + ...customTesters, + iterableEquality, + subsetEquality, + ]) + ) { // Avoid unnecessary copy which might return Object instead of subclass. return subset; } @@ -128,7 +136,12 @@ export const getObjectSubset = ( .forEach(key => { trimmed[key] = seenReferences.has(object[key]) ? seenReferences.get(object[key]) - : getObjectSubset(object[key], subset[key], seenReferences); + : getObjectSubset( + object[key], + subset[key], + customTesters, + seenReferences, + ); }); if (Object.keys(trimmed).length > 0) { @@ -147,6 +160,7 @@ const hasIterator = (object: any) => export const iterableEquality = ( a: any, b: any, + customTesters: Array = [], /* eslint-enable @typescript-eslint/explicit-module-boundary-types */ aStack: Array = [], bStack: Array = [], @@ -178,7 +192,20 @@ export const iterableEquality = ( bStack.push(b); const iterableEqualityWithStack = (a: any, b: any) => - iterableEquality(a, b, [...aStack], [...bStack]); + iterableEquality( + a, + b, + [...filteredCustomTesters], + [...aStack], + [...bStack], + ); + + // Replace any instance of iterableEquality with the new + // iterableEqualityWithStack so we can do circular detection + const filteredCustomTesters: Array = [ + ...customTesters.filter(t => t !== iterableEquality), + iterableEqualityWithStack, + ]; if (a.size !== undefined) { if (a.size !== b.size) { @@ -189,7 +216,7 @@ export const iterableEquality = ( if (!b.has(aValue)) { let has = false; for (const bValue of b) { - const isEqual = equals(aValue, bValue, [iterableEqualityWithStack]); + const isEqual = equals(aValue, bValue, filteredCustomTesters); if (isEqual === true) { has = true; } @@ -213,19 +240,23 @@ export const iterableEquality = ( for (const aEntry of a) { if ( !b.has(aEntry[0]) || - !equals(aEntry[1], b.get(aEntry[0]), [iterableEqualityWithStack]) + !equals(aEntry[1], b.get(aEntry[0]), filteredCustomTesters) ) { let has = false; for (const bEntry of b) { - const matchedKey = equals(aEntry[0], bEntry[0], [ - iterableEqualityWithStack, - ]); + const matchedKey = equals( + aEntry[0], + bEntry[0], + filteredCustomTesters, + ); let matchedValue = false; if (matchedKey === true) { - matchedValue = equals(aEntry[1], bEntry[1], [ - iterableEqualityWithStack, - ]); + matchedValue = equals( + aEntry[1], + bEntry[1], + filteredCustomTesters, + ); } if (matchedValue === true) { has = true; @@ -249,10 +280,7 @@ export const iterableEquality = ( for (const aValue of a) { const nextB = bIterator.next(); - if ( - nextB.done || - !equals(aValue, nextB.value, [iterableEqualityWithStack]) - ) { + if (nextB.done || !equals(aValue, nextB.value, filteredCustomTesters)) { return false; } } @@ -290,7 +318,10 @@ const isObjectWithKeys = (a: any) => export const subsetEquality = ( object: unknown, subset: unknown, + customTesters: Array = [], ): boolean | undefined => { + const filteredCustomTesters = customTesters.filter(t => t !== subsetEquality); + // subsetEquality needs to keep track of the references // it has already visited to avoid infinite loops in case // there are circular references in the subset passed to it. @@ -304,7 +335,7 @@ export const subsetEquality = ( return Reflect.ownKeys(subset).every(key => { if (isObjectWithKeys(subset[key])) { if (seenReferences.has(subset[key])) { - return equals(object[key], subset[key], [iterableEquality]); + return equals(object[key], subset[key], filteredCustomTesters); } seenReferences.set(subset[key], true); } @@ -312,7 +343,7 @@ export const subsetEquality = ( object != null && hasPropertyInObject(object, key) && equals(object[key], subset[key], [ - iterableEquality, + ...filteredCustomTesters, subsetEqualityWithContext(seenReferences), ]); // The main goal of using seenReference is to avoid circular node on tree. @@ -366,6 +397,7 @@ export const arrayBufferEquality = ( export const sparseArrayEquality = ( a: unknown, b: unknown, + customTesters: Array = [], ): boolean | undefined => { if (!Array.isArray(a) || !Array.isArray(b)) { return undefined; @@ -375,7 +407,12 @@ export const sparseArrayEquality = ( const aKeys = Object.keys(a); const bKeys = Object.keys(b); return ( - equals(a, b, [iterableEquality, typeEquality], true) && equals(aKeys, bKeys) + equals( + a, + b, + customTesters.filter(t => t !== sparseArrayEquality), + true, + ) && equals(aKeys, bKeys) ); }; diff --git a/packages/expect/__typetests__/expect.test.ts b/packages/expect/__typetests__/expect.test.ts index 9bc1ccae24cb..a34f1c28f4ee 100644 --- a/packages/expect/__typetests__/expect.test.ts +++ b/packages/expect/__typetests__/expect.test.ts @@ -6,12 +6,14 @@ */ import {expectAssignable, expectError, expectType} from 'tsd-lite'; -import type {EqualsFunction, Tester} from '@jest/expect-utils'; +import type {EqualsFunction} from '@jest/expect-utils'; import { MatcherContext, MatcherFunction, MatcherFunctionWithContext, Matchers, + Tester, + TesterContext, expect, } from 'expect'; import type * as jestMatcherUtils from 'jest-matcher-utils'; @@ -22,6 +24,36 @@ expectError(() => { type E = Matchers; }); +const tester1: Tester = function (a, b, customTesters) { + expectType(a); + expectType(b); + expectType>(customTesters); + expectType(this); + expectType(this.equals); + return undefined; +}; + +expectType( + expect.addEqualityTesters([ + tester1, + (a, b, customTesters) => { + expectType(a); + expectType(b); + expectType>(customTesters); + expectType(this); + return true; + }, + function anotherTester(a, b, customTesters) { + expectType(a); + expectType(b); + expectType>(customTesters); + expectType(this); + expectType(this.equals); + return undefined; + }, + ]), +); + // extend type MatcherUtils = typeof jestMatcherUtils & { @@ -35,6 +67,7 @@ expectType( toBeWithinRange(actual: number, floor: number, ceiling: number) { expectType(this.assertionCalls); expectType(this.currentTestName); + expectType>(this.customTesters); expectType<() => void>(this.dontThrow); expectType(this.error); expectType(this.equals); diff --git a/packages/expect/src/__tests__/__snapshots__/customEqualityTesters.test.ts.snap b/packages/expect/src/__tests__/__snapshots__/customEqualityTesters.test.ts.snap new file mode 100644 index 000000000000..7f36dfe58990 --- /dev/null +++ b/packages/expect/src/__tests__/__snapshots__/customEqualityTesters.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`with custom equality testers toMatchObject error shows Volume objects as equal 1`] = ` +"expect(received).toMatchObject(expected) + +- Expected - 1 ++ Received + 1 + + Object { +- "a": 2, ++ "a": 1, + "b": Volume { + "amount": 1000, + "unit": "mL", + }, + }" +`; diff --git a/packages/expect/src/__tests__/__snapshots__/customEqualityTestersRecursive.test.ts.snap b/packages/expect/src/__tests__/__snapshots__/customEqualityTestersRecursive.test.ts.snap new file mode 100644 index 000000000000..500263eded6b --- /dev/null +++ b/packages/expect/src/__tests__/__snapshots__/customEqualityTestersRecursive.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`with custom equality testers toMatchObject error shows Book objects as equal 1`] = ` +"expect(received).toMatchObject(expected) + +- Expected - 1 ++ Received + 1 + +@@ -1,7 +1,7 @@ + Object { +- "a": 2, ++ "a": 1, + "b": Book { + "__connection": 5, + "authors": Array [ + Author { + "__connection": 3," +`; diff --git a/packages/expect/src/__tests__/customEqualityTesters.test.ts b/packages/expect/src/__tests__/customEqualityTesters.test.ts new file mode 100644 index 000000000000..6a6f4cf63c17 --- /dev/null +++ b/packages/expect/src/__tests__/customEqualityTesters.test.ts @@ -0,0 +1,191 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {Tester} from '@jest/expect-utils'; +import jestExpect from '../'; + +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; + } + } +} + +function createVolume(amount: number, unit: 'L' | 'mL' = 'L') { + return new Volume(amount, unit); +} + +function isVolume(a: unknown): a is Volume { + return a instanceof Volume; +} + +const areVolumesEqual: Tester = ( + a: unknown, + b: unknown, +): boolean | undefined => { + const isAVolume = isVolume(a); + const isBVolume = isVolume(b); + + if (isAVolume && isBVolume) { + return a.equals(b); + } else if (isAVolume !== isBVolume) { + return false; + } else { + return undefined; + } +}; + +function* toIterator(array: Array): Iterator { + for (const obj of array) { + yield obj; + } +} + +declare module '../types' { + interface Matchers { + toEqualVolume(expected: Volume): R; + } +} + +jestExpect.extend({ + toEqualVolume(expected: Volume, actual: Volume) { + const result = this.equals(expected, actual, this.customTesters); + + return { + message: () => + `Expected Volume object: ${expected.toString()}. Actual Volume object: ${actual.toString()}`, + pass: result, + }; + }, +}); + +// Create Volumes with different specifications but the same value for use in +// tests. Without the custom tester, these volumes would not be equal because +// their properties have different values. However, with our custom tester they +// are equal. +const volume1 = createVolume(1, 'L'); +const volume2 = createVolume(1000, 'mL'); + +const volumeArg1 = createVolume(1, 'L'); +const volumeArg2 = createVolume(1000, 'mL'); +const volumeArg3 = createVolume(2, 'L'); +const volumeArg4 = createVolume(2000, 'mL'); + +const volumeReturn1 = createVolume(2, 'L'); +const volumeReturn2 = createVolume(2000, 'mL'); + +const testArgs = [volumeArg1, volumeArg2, [volumeArg3, volumeArg4]]; +// Swap the order of args to assert custom tester sees these volumes as equal +const expectedArgs = [volumeArg2, volumeArg1, [volumeArg4, volumeArg3]]; + +expect.addEqualityTesters([areVolumesEqual]); + +describe('with custom equality testers', () => { + it('basic matchers customTesters do not apply to still do not pass different Volume objects', () => { + expect(volume1).not.toBe(volume2); + expect([volume1]).not.toContain(volume2); + }); + + it('basic matchers pass different Volume objects', () => { + expect(volume1).toEqual(volume1); + expect(volume1).toEqual(volume2); + expect([volume1, volume2]).toEqual([volume2, volume1]); + expect(new Map([['key', volume1]])).toEqual(new Map([['key', volume2]])); + expect(new Set([volume1])).toEqual(new Set([volume2])); + expect(toIterator([volume1, volume2])).toEqual( + toIterator([volume2, volume1]), + ); + expect([volume1]).toContainEqual(volume2); + expect({a: volume1}).toHaveProperty('a', volume2); + expect({a: volume1, b: undefined}).toStrictEqual({ + a: volume2, + b: undefined, + }); + expect({a: 1, b: {c: volume1}}).toMatchObject({ + a: 1, + b: {c: volume2}, + }); + }); + + it('asymmetric matchers pass different Volume objects', () => { + expect([volume1]).toEqual(expect.arrayContaining([volume2])); + expect({a: 1, b: {c: volume1}}).toEqual( + expect.objectContaining({b: {c: volume2}}), + ); + }); + + it('spy matchers pass different Volume objects', () => { + const mockFn = jest.fn<(...args: Array) => unknown>( + () => volumeReturn1, + ); + mockFn(...testArgs); + + expect(mockFn).toHaveBeenCalledWith(...expectedArgs); + expect(mockFn).toHaveBeenLastCalledWith(...expectedArgs); + expect(mockFn).toHaveBeenNthCalledWith(1, ...expectedArgs); + + expect(mockFn).toHaveReturnedWith(volumeReturn2); + expect(mockFn).toHaveLastReturnedWith(volumeReturn2); + expect(mockFn).toHaveNthReturnedWith(1, volumeReturn2); + }); + + it('custom matchers pass different Volume objects', () => { + expect(volume1).toEqualVolume(volume2); + }); + + it('toBe recommends toStrictEqual even with different Volume objects', () => { + expect(() => expect(volume1).toBe(volume2)).toThrow('toStrictEqual'); + }); + + it('toBe recommends toEqual even with different Volume objects', () => { + expect(() => expect({a: undefined, b: volume1}).toBe({b: volume2})).toThrow( + 'toEqual', + ); + }); + + it('toContains recommends toContainEquals even with different Volume objects', () => { + expect(() => expect([volume1]).toContain(volume2)).toThrow( + 'toContainEqual', + ); + }); + + it('toMatchObject error shows Volume objects as equal', () => { + expect(() => + expect({a: 1, b: volume1}).toMatchObject({a: 2, b: volume2}), + ).toThrowErrorMatchingSnapshot(); + }); + + it('iterableEquality still properly detects cycles', () => { + const a = new Set(); + a.add(volume1); + a.add(a); + + const b = new Set(); + b.add(volume2); + b.add(b); + + expect(a).toEqual(b); + }); +}); diff --git a/packages/expect/src/__tests__/customEqualityTestersRecursive.test.ts b/packages/expect/src/__tests__/customEqualityTestersRecursive.test.ts new file mode 100644 index 000000000000..51f1b54acfa7 --- /dev/null +++ b/packages/expect/src/__tests__/customEqualityTestersRecursive.test.ts @@ -0,0 +1,247 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {Tester, equals} from '@jest/expect-utils'; +import jestExpect from '../'; + +// Test test file demonstrates and tests the capability of recursive custom +// testers that call `equals` within their tester logic. These testers should +// receive the array of custom testers and be able to pass it into equals + +const CONNECTION_PROP = '__connection'; +type DbConnection = number; +let DbConnectionId = 0; + +class Author { + public name: string; + public [CONNECTION_PROP]: DbConnection; + + constructor(name: string) { + this.name = name; + this[CONNECTION_PROP] = DbConnectionId++; + } +} + +class Book { + public name: string; + public authors: Array; + public [CONNECTION_PROP]: DbConnection; + + constructor(name: string, authors: Array) { + this.name = name; + this.authors = authors; + this[CONNECTION_PROP] = DbConnectionId++; + } +} + +const areAuthorsEqual: Tester = (a: unknown, b: unknown) => { + const isAAuthor = a instanceof Author; + const isBAuthor = b instanceof Author; + + if (isAAuthor && isBAuthor) { + return a.name === b.name; + } else if (isAAuthor !== isBAuthor) { + return false; + } else { + return undefined; + } +}; + +const areBooksEqual: Tester = function ( + a: unknown, + b: unknown, + customTesters: Array, +) { + const isABook = a instanceof Book; + const isBBook = b instanceof Book; + + if (isABook && isBBook) { + return ( + a.name === b.name && this.equals(a.authors, b.authors, customTesters) + ); + } else if (isABook !== isBBook) { + return false; + } else { + return undefined; + } +}; + +function* toIterator(array: Array): Iterator { + for (const obj of array) { + yield obj; + } +} + +declare module '../types' { + interface Matchers { + toEqualBook(expected: Book): R; + } +} + +jestExpect.extend({ + toEqualBook(expected: Book, actual: Book) { + const result = this.equals(expected, actual, this.customTesters); + + return { + message: () => + `Expected Book object: ${expected.name}. Actual Book object: ${actual.name}`, + pass: result, + }; + }, +}); + +// Create books with the same name and authors for use in tests. Without the +// custom tester, these books would not be equal because their DbConnections +// would have different values. However, with our custom tester they are equal. +const book1 = new Book('Book 1', [ + new Author('Author 1'), + new Author('Author 2'), +]); +const book1b = new Book('Book 1', [ + new Author('Author 1'), + new Author('Author 2'), +]); + +const bookArg1a = new Book('Book Arg 1', [ + new Author('Author Arg 1'), + new Author('Author Arg 2'), +]); +const bookArg1b = new Book('Book Arg 1', [ + new Author('Author Arg 1'), + new Author('Author Arg 2'), +]); +const bookArg2a = new Book('Book Arg 2', [ + new Author('Author Arg 3'), + new Author('Author Arg 4'), +]); +const bookArg2b = new Book('Book Arg 2', [ + new Author('Author Arg 3'), + new Author('Author Arg 4'), +]); + +const bookReturn1a = new Book('Book Return 1', [ + new Author('Author Return 1'), + new Author('Author Return 2'), +]); +const bookReturn1b = new Book('Book Return 1', [ + new Author('Author Return 1'), + new Author('Author Return 2'), +]); + +const testArgs = [bookArg1a, bookArg1b, [bookArg2a, bookArg2b]]; +// Swap the order of args to assert custom tester works correctly and ignores +// DbConnection differences +const expectedArgs = [bookArg1b, bookArg1a, [bookArg2b, bookArg2a]]; + +expect.addEqualityTesters([areAuthorsEqual, areBooksEqual]); + +describe('with custom equality testers', () => { + it('exposes an equality function to custom testers', () => { + const runTestSymbol = Symbol('run this test'); + + // jestExpect and expect share the same global state + expect.assertions(3); + jestExpect.addEqualityTesters([ + function dummyTester(a) { + // Equality testers are globally added. Only run this assertion for this test + if (a === runTestSymbol) { + expect(this.equals).toBe(equals); + return true; + } + + return undefined; + }, + ]); + + expect(() => + jestExpect(runTestSymbol).toEqual(runTestSymbol), + ).not.toThrow(); + }); + + it('basic matchers customTesters do not apply to still do not pass different Book objects', () => { + expect(book1).not.toBe(book1b); + expect([book1]).not.toContain(book1b); + }); + + it('basic matchers pass different Book objects', () => { + expect(book1).toEqual(book1); + expect(book1).toEqual(book1b); + expect([book1, book1b]).toEqual([book1b, book1]); + expect(new Map([['key', book1]])).toEqual(new Map([['key', book1b]])); + expect(new Set([book1])).toEqual(new Set([book1b])); + expect(toIterator([book1, book1b])).toEqual(toIterator([book1b, book1])); + expect([book1]).toContainEqual(book1b); + expect({a: book1}).toHaveProperty('a', book1b); + expect({a: book1, b: undefined}).toStrictEqual({ + a: book1b, + b: undefined, + }); + expect({a: 1, b: {c: book1}}).toMatchObject({ + a: 1, + b: {c: book1b}, + }); + }); + + it('asymmetric matchers pass different Book objects', () => { + expect([book1]).toEqual(expect.arrayContaining([book1b])); + expect({a: 1, b: {c: book1}}).toEqual( + expect.objectContaining({b: {c: book1b}}), + ); + }); + + it('spy matchers pass different Book objects', () => { + const mockFn = jest.fn<(...args: Array) => unknown>( + () => bookReturn1a, + ); + mockFn(...testArgs); + + expect(mockFn).toHaveBeenCalledWith(...expectedArgs); + expect(mockFn).toHaveBeenLastCalledWith(...expectedArgs); + expect(mockFn).toHaveBeenNthCalledWith(1, ...expectedArgs); + + expect(mockFn).toHaveReturnedWith(bookReturn1b); + expect(mockFn).toHaveLastReturnedWith(bookReturn1b); + expect(mockFn).toHaveNthReturnedWith(1, bookReturn1b); + }); + + it('custom matchers pass different Book objects', () => { + expect(book1).toEqualBook(book1b); + }); + + it('toBe recommends toStrictEqual even with different Book objects', () => { + expect(() => expect(book1).toBe(book1b)).toThrow('toStrictEqual'); + }); + + it('toBe recommends toEqual even with different Book objects', () => { + expect(() => expect({a: undefined, b: book1}).toBe({b: book1b})).toThrow( + 'toEqual', + ); + }); + + it('toContains recommends toContainEquals even with different Book objects', () => { + expect(() => expect([book1]).toContain(book1b)).toThrow('toContainEqual'); + }); + + it('toMatchObject error shows Book objects as equal', () => { + expect(() => + expect({a: 1, b: book1}).toMatchObject({a: 2, b: book1b}), + ).toThrowErrorMatchingSnapshot(); + }); + + it('iterableEquality still properly detects cycles', () => { + const a = new Set(); + a.add(book1); + a.add(a); + + const b = new Set(); + b.add(book1b); + b.add(b); + + expect(a).toEqual(b); + }); +}); diff --git a/packages/expect/src/asymmetricMatchers.ts b/packages/expect/src/asymmetricMatchers.ts index 5c51bd921d8b..d4ab8f8f374f 100644 --- a/packages/expect/src/asymmetricMatchers.ts +++ b/packages/expect/src/asymmetricMatchers.ts @@ -14,7 +14,7 @@ import { } from '@jest/expect-utils'; import * as matcherUtils from 'jest-matcher-utils'; import {pluralize} from 'jest-util'; -import {getState} from './jestMatchersObject'; +import {getCustomEqualityTesters, getState} from './jestMatchersObject'; import type { AsymmetricMatcher as AsymmetricMatcherInterface, MatcherContext, @@ -73,6 +73,7 @@ export abstract class AsymmetricMatcher protected getMatcherContext(): MatcherContext { return { + customTesters: getCustomEqualityTesters(), // eslint-disable-next-line @typescript-eslint/no-empty-function dontThrow: () => {}, ...getState(), @@ -193,11 +194,14 @@ class ArrayContaining extends AsymmetricMatcher> { ); } + const matcherContext = this.getMatcherContext(); const result = this.sample.length === 0 || (Array.isArray(other) && this.sample.every(item => - other.some(another => equals(item, another)), + other.some(another => + equals(item, another, matcherContext.customTesters), + ), )); return this.inverse ? !result : result; @@ -227,10 +231,15 @@ class ObjectContaining extends AsymmetricMatcher> { let result = true; + const matcherContext = this.getMatcherContext(); for (const property in this.sample) { if ( !hasProperty(other, property) || - !equals(this.sample[property], other[property]) + !equals( + this.sample[property], + other[property], + matcherContext.customTesters, + ) ) { result = false; break; diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index af07fff87287..88b913e8f181 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -28,6 +28,8 @@ import { import extractExpectedAssertionsErrors from './extractExpectedAssertionsErrors'; import { INTERNAL_MATCHER_FLAG, + addCustomEqualityTesters, + getCustomEqualityTesters, getMatchers, getState, setMatchers, @@ -51,6 +53,7 @@ import type { ThrowingMatcherFn, } from './types'; +export type {Tester, TesterContext} from '@jest/expect-utils'; export {AsymmetricMatcher} from './asymmetricMatchers'; export type { AsyncExpectationResult, @@ -277,6 +280,7 @@ const makeThrowingMatcher = ( }; const matcherUtilsThing: MatcherUtils = { + customTesters: getCustomEqualityTesters(), // When throws is disabled, the matcher will not throw errors during test // execution but instead add them to the global matcher state. If a // matcher throws, test execution is normally stopped immediately. The @@ -383,6 +387,9 @@ const makeThrowingMatcher = ( expect.extend = (matchers: MatchersObject) => setMatchers(matchers, false, expect); +expect.addEqualityTesters = customTesters => + addCustomEqualityTesters(customTesters); + expect.anything = anything; expect.any = any; diff --git a/packages/expect/src/jestMatchersObject.ts b/packages/expect/src/jestMatchersObject.ts index d43aee7df5a1..2cb600be6772 100644 --- a/packages/expect/src/jestMatchersObject.ts +++ b/packages/expect/src/jestMatchersObject.ts @@ -6,6 +6,7 @@ * */ +import type {Tester} from '@jest/expect-utils'; import {getType} from 'jest-get-type'; import {AsymmetricMatcher} from './asymmetricMatchers'; import type { @@ -32,6 +33,7 @@ if (!Object.prototype.hasOwnProperty.call(globalThis, JEST_MATCHERS_OBJECT)) { }; Object.defineProperty(globalThis, JEST_MATCHERS_OBJECT, { value: { + customEqualityTesters: [], matchers: Object.create(null), state: defaultState, }, @@ -122,3 +124,20 @@ export const setMatchers = ( Object.assign((globalThis as any)[JEST_MATCHERS_OBJECT].matchers, matchers); }; + +export const getCustomEqualityTesters = (): Array => + (globalThis as any)[JEST_MATCHERS_OBJECT].customEqualityTesters; + +export const addCustomEqualityTesters = (newTesters: Array): void => { + if (!Array.isArray(newTesters)) { + throw new TypeError( + `expect.customEqualityTesters: Must be set to an array of Testers. Was given "${getType( + newTesters, + )}"`, + ); + } + + (globalThis as any)[JEST_MATCHERS_OBJECT].customEqualityTesters.push( + ...newTesters, + ); +}; diff --git a/packages/expect/src/matchers.ts b/packages/expect/src/matchers.ts index 454a5c288efa..49cda23321fb 100644 --- a/packages/expect/src/matchers.ts +++ b/packages/expect/src/matchers.ts @@ -97,9 +97,21 @@ const matchers: MatchersObject = { if (expectedType !== 'map' && expectedType !== 'set') { // If deep equality passes when referential identity fails, // but exclude map and set until review of their equality logic. - if (equals(received, expected, toStrictEqualTesters, true)) { + if ( + equals( + received, + expected, + [...this.customTesters, ...toStrictEqualTesters], + true, + ) + ) { deepEqualityName = 'toStrictEqual'; - } else if (equals(received, expected, [iterableEquality])) { + } else if ( + equals(received, expected, [ + ...this.customTesters, + iterableEquality, + ]) + ) { deepEqualityName = 'toEqual'; } } @@ -540,7 +552,7 @@ const matchers: MatchersObject = { }` + (!isNot && indexable.findIndex(item => - equals(item, expected, [iterableEquality]), + equals(item, expected, [...this.customTesters, iterableEquality]), ) !== -1 ? `\n\n${SUGGEST_TO_CONTAIN_EQUAL}` : '') @@ -570,7 +582,7 @@ const matchers: MatchersObject = { } const index = Array.from(received).findIndex(item => - equals(item, expected, [iterableEquality]), + equals(item, expected, [...this.customTesters, iterableEquality]), ); const pass = index !== -1; @@ -605,7 +617,10 @@ const matchers: MatchersObject = { promise: this.promise, }; - const pass = equals(received, expected, [iterableEquality]); + const pass = equals(received, expected, [ + ...this.customTesters, + iterableEquality, + ]); const message = pass ? () => @@ -748,7 +763,10 @@ const matchers: MatchersObject = { const pass = hasValue && endPropIsDefined - ? equals(value, expectedValue, [iterableEquality]) + ? equals(value, expectedValue, [ + ...this.customTesters, + iterableEquality, + ]) : Boolean(hasEndProp); const message = pass @@ -896,7 +914,11 @@ const matchers: MatchersObject = { ); } - const pass = equals(received, expected, [iterableEquality, subsetEquality]); + const pass = equals(received, expected, [ + ...this.customTesters, + iterableEquality, + subsetEquality, + ]); const message = pass ? () => @@ -913,7 +935,7 @@ const matchers: MatchersObject = { '\n\n' + printDiffOrStringify( expected, - getObjectSubset(received, expected), + getObjectSubset(received, expected, this.customTesters), EXPECTED_LABEL, RECEIVED_LABEL, isExpand(this.expand), @@ -930,7 +952,12 @@ const matchers: MatchersObject = { promise: this.promise, }; - const pass = equals(received, expected, toStrictEqualTesters, true); + const pass = equals( + received, + expected, + [...this.customTesters, ...toStrictEqualTesters], + true, + ); const message = pass ? () => diff --git a/packages/expect/src/spyMatchers.ts b/packages/expect/src/spyMatchers.ts index bb7b9e1e8702..3c7c1e1decb4 100644 --- a/packages/expect/src/spyMatchers.ts +++ b/packages/expect/src/spyMatchers.ts @@ -22,6 +22,7 @@ import { printWithType, stringify, } from 'jest-matcher-utils'; +import {getCustomEqualityTesters} from './jestMatchersObject'; import type { MatcherFunction, MatchersObject, @@ -59,7 +60,7 @@ const printReceivedArgs = ( const printCommon = (val: unknown) => DIM_COLOR(stringify(val)); const isEqualValue = (expected: unknown, received: unknown): boolean => - equals(expected, received, [iterableEquality]); + equals(expected, received, [...getCustomEqualityTesters(), iterableEquality]); const isEqualCall = ( expected: Array, diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index e2fc979165ff..2f449b124d94 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -46,6 +46,7 @@ export type ThrowingMatcherFn = (actual: any) => void; export type PromiseMatcherFn = (actual: any) => Promise; export interface MatcherUtils { + customTesters: Array; dontThrow(): void; equals: EqualsFunction; utils: typeof jestMatcherUtils & { @@ -86,6 +87,7 @@ export type ExpectedAssertionsErrors = Array<{ export interface BaseExpect { assertions(numberOfAssertions: number): void; + addEqualityTesters(testers: Array): void; extend(matchers: MatchersObject): void; extractExpectedAssertionsErrors(): ExpectedAssertionsErrors; getState(): MatcherState;