From 463bee38463cd66f9b1f11983a787d9e0e3a7dea Mon Sep 17 00:00:00 2001 From: Tigran <50393947+tigranmk@users.noreply.github.com> Date: Fri, 12 Jan 2024 19:41:39 +0400 Subject: [PATCH] feat: allow extending toEqual (fix #2875) (#4880) Co-authored-by: Vladimir --- docs/api/expect.md | 52 ++++ packages/expect/src/index.ts | 1 + .../expect/src/jest-asymmetric-matchers.ts | 10 +- packages/expect/src/jest-expect.ts | 21 +- packages/expect/src/jest-extend.ts | 5 +- packages/expect/src/jest-matcher-utils.ts | 23 +- packages/expect/src/jest-utils.ts | 52 ++-- packages/expect/src/state.ts | 4 +- packages/expect/src/types.ts | 18 +- .../vitest/src/integrations/chai/index.ts | 4 +- test/core/test/expect.test.ts | 233 +++++++++++++++++- 11 files changed, 380 insertions(+), 43 deletions(-) diff --git a/docs/api/expect.md b/docs/api/expect.md index 663239396d5c..242251f480a2 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -1405,3 +1405,55 @@ Don't forget to include the ambient declaration file in your `tsconfig.json`. :::tip If you want to know more, checkout [guide on extending matchers](/guide/extending-matchers). ::: + +## expect.addEqualityTesters 1.2.0+ + +- **Type:** `(tester: Array) => void` + +You can use this method to define custom testers, which are methods used by matchers, to test if two objects are equal. It is compatible with Jest's `expect.addEqualityTesters`. + +```ts +import { expect, test } from 'vitest' + +class AnagramComparator { + public word: string + + constructor(word: string) { + this.word = word + } + + equals(other: AnagramComparator): boolean { + const cleanStr1 = this.word.replace(/ /g, '').toLowerCase() + const cleanStr2 = other.word.replace(/ /g, '').toLowerCase() + + const sortedStr1 = cleanStr1.split('').sort().join('') + const sortedStr2 = cleanStr2.split('').sort().join('') + + return sortedStr1 === sortedStr2 + } +} + +function isAnagramComparator(a: unknown): a is AnagramComparator { + return a instanceof AnagramComparator +} + +function areAnagramsEqual(a: unknown, b: unknown): boolean | undefined { + const isAAnagramComparator = isAnagramComparator(a) + const isBAnagramComparator = isAnagramComparator(b) + + if (isAAnagramComparator && isBAnagramComparator) + return a.equals(b) + + else if (isAAnagramComparator === isBAnagramComparator) + return undefined + + else + return false +} + +expect.addEqualityTesters([areAnagramsEqual]) + +test('custom equality tester', () => { + expect(new AnagramComparator('listen')).toEqual(new AnagramComparator('silent')) +}) +``` \ No newline at end of file diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index b0f3466f831d..f9d311afb51b 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -4,5 +4,6 @@ export * from './constants' export * from './types' export { getState, setState } from './state' export { JestChaiExpect } from './jest-expect' +export { addCustomEqualityTesters } from './jest-matcher-utils' export { JestExtend } from './jest-extend' export { setupColors } from '@vitest/utils' diff --git a/packages/expect/src/jest-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts index e36b06e5defb..3879b02af992 100644 --- a/packages/expect/src/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -1,7 +1,7 @@ import type { ChaiPlugin, MatcherState } from './types' import { GLOBAL_EXPECT } from './constants' import { getState } from './state' -import { diff, getMatcherUtils, stringify } from './jest-matcher-utils' +import { diff, getCustomEqualityTesters, getMatcherUtils, stringify } from './jest-matcher-utils' import { equals, isA, iterableEquality, pluralize, subsetEquality } from './jest-utils' @@ -26,7 +26,7 @@ export abstract class AsymmetricMatcher< ...getState(expect || (globalThis as any)[GLOBAL_EXPECT]), equals, isNot: this.inverse, - customTesters: [], + customTesters: getCustomEqualityTesters(), utils: { ...getMatcherUtils(), diff, @@ -116,8 +116,9 @@ export class ObjectContaining extends AsymmetricMatcher> let result = true + const matcherContext = this.getMatcherContext() for (const property in this.sample) { - if (!this.hasProperty(other, property) || !equals(this.sample[property], other[property])) { + if (!this.hasProperty(other, property) || !equals(this.sample[property], other[property], matcherContext.customTesters)) { result = false break } @@ -149,11 +150,12 @@ export 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 diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index 0434c3705233..e6b5a71e1183 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -6,7 +6,7 @@ import type { Test } from '@vitest/runner' import type { Assertion, ChaiPlugin } from './types' import { arrayBufferEquality, generateToBeMessage, iterableEquality, equals as jestEquals, sparseArrayEquality, subsetEquality, typeEquality } from './jest-utils' import type { AsymmetricMatcher } from './jest-asymmetric-matchers' -import { diff, stringify } from './jest-matcher-utils' +import { diff, getCustomEqualityTesters, stringify } from './jest-matcher-utils' import { JEST_MATCHERS_OBJECT } from './constants' import { recordAsyncExpect, wrapSoft } from './utils' @@ -23,6 +23,7 @@ declare class DOMTokenList { export const JestChaiExpect: ChaiPlugin = (chai, utils) => { const { AssertionError } = chai const c = () => getColors() + const customTesters = getCustomEqualityTesters() function def(name: keyof Assertion | (keyof Assertion)[], fn: ((this: Chai.AssertionStatic & Assertion, ...args: any[]) => any)) { const addMethod = (n: keyof Assertion) => { @@ -80,7 +81,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { const equal = jestEquals( actual, expected, - [iterableEquality], + [...customTesters, iterableEquality], ) return this.assert( @@ -98,6 +99,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { obj, expected, [ + ...customTesters, iterableEquality, typeEquality, sparseArrayEquality, @@ -125,6 +127,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { actual, expected, [ + ...customTesters, iterableEquality, typeEquality, sparseArrayEquality, @@ -140,7 +143,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { const toEqualPass = jestEquals( actual, expected, - [iterableEquality], + [...customTesters, iterableEquality], ) if (toEqualPass) @@ -159,7 +162,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { def('toMatchObject', function (expected) { const actual = this._obj return this.assert( - jestEquals(actual, expected, [iterableEquality, subsetEquality]), + jestEquals(actual, expected, [...customTesters, iterableEquality, subsetEquality]), 'expected #{this} to match object #{exp}', 'expected #{this} to not match object #{exp}', expected, @@ -208,7 +211,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { def('toContainEqual', function (expected) { const obj = utils.flag(this, 'object') const index = Array.from(obj).findIndex((item) => { - return jestEquals(item, expected) + return jestEquals(item, expected, customTesters) }) this.assert( @@ -339,7 +342,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return utils.getPathInfo(actual, propertyName) } const { value, exists } = getValue() - const pass = exists && (args.length === 1 || jestEquals(expected, value)) + const pass = exists && (args.length === 1 || jestEquals(expected, value, customTesters)) const valueString = args.length === 1 ? '' : ` with value ${utils.objDisplay(expected)}` @@ -482,7 +485,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { def(['toHaveBeenCalledWith', 'toBeCalledWith'], function (...args) { const spy = getSpy(this) const spyName = spy.getMockName() - const pass = spy.mock.calls.some(callArg => jestEquals(callArg, args, [iterableEquality])) + const pass = spy.mock.calls.some(callArg => jestEquals(callArg, args, [...customTesters, iterableEquality])) const isNot = utils.flag(this, 'negate') as boolean const msg = utils.getMessage( @@ -504,7 +507,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { const nthCall = spy.mock.calls[times - 1] this.assert( - jestEquals(nthCall, args, [iterableEquality]), + jestEquals(nthCall, args, [...customTesters, iterableEquality]), `expected ${ordinalOf(times)} "${spyName}" call to have been called with #{exp}`, `expected ${ordinalOf(times)} "${spyName}" call to not have been called with #{exp}`, args, @@ -517,7 +520,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { const lastCall = spy.mock.calls[spy.mock.calls.length - 1] this.assert( - jestEquals(lastCall, args, [iterableEquality]), + jestEquals(lastCall, args, [...customTesters, iterableEquality]), `expected last "${spyName}" call to have been called with #{exp}`, `expected last "${spyName}" call to not have been called with #{exp}`, args, diff --git a/packages/expect/src/jest-extend.ts b/packages/expect/src/jest-extend.ts index fee75069895e..27c53f843e71 100644 --- a/packages/expect/src/jest-extend.ts +++ b/packages/expect/src/jest-extend.ts @@ -10,7 +10,7 @@ import { ASYMMETRIC_MATCHERS_OBJECT, JEST_MATCHERS_OBJECT } from './constants' import { AsymmetricMatcher } from './jest-asymmetric-matchers' import { getState } from './state' -import { diff, getMatcherUtils, stringify } from './jest-matcher-utils' +import { diff, getCustomEqualityTesters, getMatcherUtils, stringify } from './jest-matcher-utils' import { equals, @@ -33,8 +33,7 @@ function getMatcherState(assertion: Chai.AssertionStatic & Chai.Assertion, expec const matcherState: MatcherState = { ...getState(expect), - // TODO: implement via expect.addEqualityTesters - customTesters: [], + customTesters: getCustomEqualityTesters(), isNot, utils: jestUtils, promise, diff --git a/packages/expect/src/jest-matcher-utils.ts b/packages/expect/src/jest-matcher-utils.ts index 32dcc2c0319e..ad2267d9688b 100644 --- a/packages/expect/src/jest-matcher-utils.ts +++ b/packages/expect/src/jest-matcher-utils.ts @@ -1,5 +1,6 @@ -import { getColors, stringify } from '@vitest/utils' -import type { MatcherHintOptions } from './types' +import { getColors, getType, stringify } from '@vitest/utils' +import type { MatcherHintOptions, Tester } from './types' +import { JEST_MATCHERS_OBJECT } from './constants' export { diff } from '@vitest/utils/diff' export { stringify } @@ -101,3 +102,21 @@ export function getMatcherUtils() { printExpected, } } + +export function 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, + ) +} + +export function getCustomEqualityTesters(): Array { + return (globalThis as any)[JEST_MATCHERS_OBJECT].customEqualityTesters +} diff --git a/packages/expect/src/jest-utils.ts b/packages/expect/src/jest-utils.ts index a6d842043ae1..a71cee767af6 100644 --- a/packages/expect/src/jest-utils.ts +++ b/packages/expect/src/jest-utils.ts @@ -23,7 +23,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import { isObject } from '@vitest/utils' -import type { Tester } from './types' +import type { Tester, TesterContext } from './types' // Extracted out of jasmine 2.5.2 export function equals( @@ -87,8 +87,9 @@ function eq( if (asymmetricResult !== undefined) 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 } @@ -298,7 +299,7 @@ function hasIterator(object: any) { return !!(object != null && object[IteratorSymbol]) } -export function iterableEquality(a: any, b: any, aStack: Array = [], bStack: Array = []): boolean | undefined { +export function iterableEquality(a: any, b: any, customTesters: Array = [], aStack: Array = [], bStack: Array = []): boolean | undefined { if ( typeof a !== 'object' || typeof b !== 'object' @@ -324,7 +325,20 @@ export function iterableEquality(a: any, b: any, aStack: Array = [], bStack aStack.push(a) bStack.push(b) - const iterableEqualityWithStack = (a: any, b: any) => iterableEquality(a, b, [...aStack], [...bStack]) + const filteredCustomTesters: Array = [ + ...customTesters.filter(t => t !== iterableEquality), + iterableEqualityWithStack, + ] + + function iterableEqualityWithStack(a: any, b: any) { + return iterableEquality( + a, + b, + [...filteredCustomTesters], + [...aStack], + [...bStack], + ) + } if (a.size !== undefined) { if (a.size !== b.size) { @@ -336,7 +350,7 @@ export function iterableEquality(a: any, b: any, aStack: Array = [], bStack 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 } @@ -357,20 +371,16 @@ export function iterableEquality(a: any, b: any, aStack: Array = [], bStack 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, - ]) - } + if (matchedKey === true) + matchedValue = equals(aEntry[1], bEntry[1], filteredCustomTesters) + if (matchedValue === true) has = true } @@ -394,7 +404,7 @@ export function iterableEquality(a: any, b: any, aStack: Array = [], bStack const nextB = bIterator.next() if ( nextB.done - || !equals(aValue, nextB.value, [iterableEqualityWithStack]) + || !equals(aValue, nextB.value, filteredCustomTesters) ) return false } @@ -430,7 +440,8 @@ function isObjectWithKeys(a: any) { && !(a instanceof Date) } -export function subsetEquality(object: unknown, subset: unknown): boolean | undefined { +export function 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. @@ -443,7 +454,7 @@ export function subsetEquality(object: unknown, subset: unknown): boolean | unde return Object.keys(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) } @@ -451,7 +462,7 @@ export function subsetEquality(object: unknown, subset: unknown): boolean | unde = 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. @@ -504,15 +515,16 @@ export function arrayBufferEquality(a: unknown, b: unknown): boolean | undefined return true } -export function sparseArrayEquality(a: unknown, b: unknown): boolean | undefined { +export function sparseArrayEquality(a: unknown, b: unknown, customTesters: Array = []): boolean | undefined { if (!Array.isArray(a) || !Array.isArray(b)) return undefined // A sparse array [, , 1] will have keys ["2"] whereas [undefined, undefined, 1] will have keys ["0", "1", "2"] const aKeys = Object.keys(a) const bKeys = Object.keys(b) + const filteredCustomTesters = customTesters.filter(t => t !== sparseArrayEquality) return ( - equals(a, b, [iterableEquality, typeEquality], true) && equals(aKeys, bKeys) + equals(a, b, filteredCustomTesters, true) && equals(aKeys, bKeys) ) } diff --git a/packages/expect/src/state.ts b/packages/expect/src/state.ts index 7600a6d8dda0..ee116f5b4e7d 100644 --- a/packages/expect/src/state.ts +++ b/packages/expect/src/state.ts @@ -1,9 +1,10 @@ -import type { ExpectStatic, MatcherState } from './types' +import type { ExpectStatic, MatcherState, Tester } from './types' import { ASYMMETRIC_MATCHERS_OBJECT, GLOBAL_EXPECT, JEST_MATCHERS_OBJECT, MATCHERS_OBJECT } from './constants' if (!Object.prototype.hasOwnProperty.call(globalThis, MATCHERS_OBJECT)) { const globalState = new WeakMap() const matchers = Object.create(null) + const customEqualityTesters: Array = [] const assymetricMatchers = Object.create(null) Object.defineProperty(globalThis, MATCHERS_OBJECT, { get: () => globalState, @@ -13,6 +14,7 @@ if (!Object.prototype.hasOwnProperty.call(globalThis, MATCHERS_OBJECT)) { get: () => ({ state: globalState.get((globalThis as any)[GLOBAL_EXPECT]), matchers, + customEqualityTesters, }), }) Object.defineProperty(globalThis, ASYMMETRIC_MATCHERS_OBJECT, { diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 4a05a086454a..44df324761fc 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -12,8 +12,21 @@ import type { diff, getMatcherUtils, stringify } from './jest-matcher-utils' export type ChaiPlugin = Chai.ChaiPlugin -export type Tester = (a: any, b: any) => boolean | undefined - +export type Tester = ( + this: TesterContext, + a: any, + b: any, + customTesters: Array, +) => boolean | undefined + +export interface TesterContext { + equals: ( + a: unknown, + b: unknown, + customTesters?: Array, + strictCheck?: boolean, + ) => boolean +} export type { DiffOptions } from '@vitest/utils/diff' export interface MatcherHintOptions { @@ -81,6 +94,7 @@ export interface ExpectStatic extends Chai.ExpectStatic, AsymmetricMatchersConta unreachable(message?: string): never soft(actual: T, message?: string): Assertion extend(expects: MatchersObject): void + addEqualityTesters(testers: Array): void assertions(expected: number): void hasAssertions(): void anything(): any diff --git a/packages/vitest/src/integrations/chai/index.ts b/packages/vitest/src/integrations/chai/index.ts index 3422094956d5..fb0f6d0bca1f 100644 --- a/packages/vitest/src/integrations/chai/index.ts +++ b/packages/vitest/src/integrations/chai/index.ts @@ -4,7 +4,7 @@ import * as chai from 'chai' import './setup' import type { TaskPopulated, Test } from '@vitest/runner' import { getCurrentTest } from '@vitest/runner' -import { ASYMMETRIC_MATCHERS_OBJECT, GLOBAL_EXPECT, getState, setState } from '@vitest/expect' +import { ASYMMETRIC_MATCHERS_OBJECT, GLOBAL_EXPECT, addCustomEqualityTesters, getState, setState } from '@vitest/expect' import type { Assertion, ExpectStatic } from '@vitest/expect' import type { MatcherState } from '../../types/chai' import { getFullName } from '../../utils/tasks' @@ -46,6 +46,8 @@ export function createExpect(test?: TaskPopulated) { // @ts-expect-error untyped expect.extend = matchers => chai.expect.extend(expect, matchers) + expect.addEqualityTesters = customTesters => + addCustomEqualityTesters(customTesters) expect.soft = (...args) => { const assert = expect(...args) diff --git a/test/core/test/expect.test.ts b/test/core/test/expect.test.ts index 8d98e2cb55c6..74bce2cac9cf 100644 --- a/test/core/test/expect.test.ts +++ b/test/core/test/expect.test.ts @@ -1,5 +1,6 @@ +import type { Tester } from '@vitest/expect' import { getCurrentTest } from '@vitest/runner' -import { describe, expect, expectTypeOf, test } from 'vitest' +import { describe, expect, expectTypeOf, test, vi } from 'vitest' describe('expect.soft', () => { test('types', () => { @@ -40,3 +41,233 @@ describe('expect.soft', () => { expect.soft('test3').toBe('test res') }) }) + +describe('expect.addEqualityTesters', () => { + class AnagramComparator { + public word: string + + constructor(word: string) { + this.word = word + } + + equals(other: AnagramComparator): boolean { + const cleanStr1 = this.word.replace(/ /g, '').toLowerCase() + const cleanStr2 = other.word.replace(/ /g, '').toLowerCase() + + const sortedStr1 = cleanStr1.split('').sort().join('') + const sortedStr2 = cleanStr2.split('').sort().join('') + + return sortedStr1 === sortedStr2 + } + } + + function createAnagramComparator(word: string) { + return new AnagramComparator(word) + } + + function isAnagramComparator(a: unknown): a is AnagramComparator { + return a instanceof AnagramComparator + } + + const areObjectsEqual: Tester = ( + a: unknown, + b: unknown, + ): boolean | undefined => { + const isAAnagramComparator = isAnagramComparator(a) + const isBAnagramComparator = isAnagramComparator(b) + + if (isAAnagramComparator && isBAnagramComparator) + return a.equals(b) + + else if (isAAnagramComparator === isBAnagramComparator) + return undefined + + else + return false + } + + function* toIterator(array: Array): Iterator { + for (const obj of array) + yield obj + } + + const customObject1 = createAnagramComparator('listen') + const customObject2 = createAnagramComparator('silent') + + expect.addEqualityTesters([areObjectsEqual]) + + test('AnagramComparator objects are unique and not contained within arrays of AnagramComparator objects', () => { + expect(customObject1).not.toBe(customObject2) + expect([customObject1]).not.toContain(customObject2) + }) + + test('basic matchers pass different AnagramComparator objects', () => { + expect(customObject1).toEqual(customObject2) + expect([customObject1, customObject2]).toEqual([customObject2, customObject1]) + expect(new Map([['key', customObject1]])).toEqual(new Map([['key', customObject2]])) + expect(new Set([customObject1])).toEqual(new Set([customObject2])) + expect(toIterator([customObject1, customObject2])).toEqual( + toIterator([customObject2, customObject1]), + ) + expect([customObject1]).toContainEqual(customObject2) + expect({ a: customObject1 }).toHaveProperty('a', customObject2) + expect({ a: customObject2, b: undefined }).toStrictEqual({ + a: customObject1, + b: undefined, + }) + expect({ a: 1, b: { c: customObject1 } }).toMatchObject({ + a: 1, + b: { c: customObject2 }, + }) + }) + + test('asymmetric matchers pass different AnagramComparator objects', () => { + expect([customObject1]).toEqual(expect.arrayContaining([customObject1])) + expect({ a: 1, b: { c: customObject1 } }).toEqual( + expect.objectContaining({ b: { c: customObject2 } }), + ) + }) + + test('toBe recommends toStrictEqual even with different objects', () => { + expect(() => expect(customObject1).toBe(customObject2)).toThrow('toStrictEqual') + }) + + test('toBe recommends toEqual even with different AnagramComparator objects', () => { + expect(() => expect({ a: undefined, b: customObject1 }).toBe({ b: customObject2 })).toThrow( + 'toEqual', + ) + }) + + test('iterableEquality still properly detects cycles', () => { + const a = new Set() + a.add(customObject1) + a.add(a) + + const b = new Set() + b.add(customObject2) + b.add(b) + + expect(a).toEqual(b) + }) +}) + +describe('recursive custom equality tester', () => { + let personId = 0 + + class Address { + public address: string + + constructor(address: string) { + this.address = address + } + } + class Person { + public name: string + public address: Address + public personId: string + + constructor(name: string, address: Address) { + this.name = name + this.address = address + this.personId = `${personId++}` + } + } + + const arePersonsEqual: Tester = function (a, b, customTesters) { + const isAPerson = a instanceof Person + const isBPerson = b instanceof Person + + if (isAPerson && isBPerson) + return a.name === b.name && this.equals(a.address, b.address, customTesters) + + else if (isAPerson === isBPerson) + return undefined + + else + return false + } + + const areAddressesEqual: Tester = (a, b) => { + const isAAddress = a instanceof Address + const isBAddress = b instanceof Address + + if (isAAddress && isBAddress) + return a.address === b.address + + else if (isAAddress === isBAddress) + return undefined + + else + return false + } + + const person1 = new Person('Luke Skywalker', new Address('Tatooine')) + const person2 = new Person('Luke Skywalker', new Address('Tatooine')) + + expect.addEqualityTesters([areAddressesEqual, arePersonsEqual]) + + test('basic matchers pass different Address objects', () => { + expect(person1).not.toBe(person2) + expect([person1]).not.toContain(person2) + expect(person1).toEqual(person1) + expect(person1).toEqual(person2) + expect([person1, person2]).toEqual([person2, person1]) + expect(new Map([['key', person1]])).toEqual(new Map([['key', person2]])) + expect(new Set([person1])).toEqual(new Set([person2])) + expect([person1]).toContainEqual(person2) + expect({ a: person1 }).toHaveProperty('a', person2) + expect({ a: person1, b: undefined }).toStrictEqual({ + a: person2, + b: undefined, + }) + expect({ a: 1, b: { c: person1 } }).toMatchObject({ + a: 1, + b: { c: person2 }, + }) + }) + + test('asymmetric matchers pass different Address objects', () => { + expect([person1]).toEqual(expect.arrayContaining([person2])) + expect({ a: 1, b: { c: person1 } }).toEqual( + expect.objectContaining({ b: { c: person2 } }), + ) + }) + + test('toBe recommends toStrictEqual even with different Address objects', () => { + expect(() => expect(person1).toBe(person2)).toThrow('toStrictEqual') + }) + + test('toBe recommends toEqual even with different Address objects', () => { + expect(() => expect({ a: undefined, b: person1 }).toBe({ b: person2 })).toThrow( + 'toEqual', + ) + }) + + test('iterableEquality still properly detects cycles', () => { + const a = new Set() + a.add(person1) + a.add(a) + + const b = new Set() + b.add(person2) + b.add(b) + + expect(a).toEqual(b) + }) + + test('spy matchers pass different Person objects', () => { + const mockFn = vi.fn( + (person: Person) => [person, person2], + ) + mockFn(person1) + + expect(mockFn).toHaveBeenCalledWith(person1) + expect(mockFn).toHaveBeenCalledWith(person1) + expect(mockFn).toHaveBeenLastCalledWith(person1) + expect(mockFn).toHaveBeenNthCalledWith(1, person1) + + expect(mockFn).toHaveReturnedWith([person1, person2]) + expect(mockFn).toHaveLastReturnedWith([person1, person2]) + expect(mockFn).toHaveNthReturnedWith(1, [person1, person2]) + }) +})