Skip to content

Commit 463bee3

Browse files
tigranmksheremet-va
andauthoredJan 12, 2024
feat: allow extending toEqual (fix #2875) (#4880)
Co-authored-by: Vladimir <sleuths.slews0s@icloud.com>
1 parent 7f59a1b commit 463bee3

File tree

11 files changed

+380
-43
lines changed

11 files changed

+380
-43
lines changed
 

‎docs/api/expect.md

+52
Original file line numberDiff line numberDiff line change
@@ -1405,3 +1405,55 @@ Don't forget to include the ambient declaration file in your `tsconfig.json`.
14051405
:::tip
14061406
If you want to know more, checkout [guide on extending matchers](/guide/extending-matchers).
14071407
:::
1408+
1409+
## expect.addEqualityTesters <Badge type="info">1.2.0+</Badge>
1410+
1411+
- **Type:** `(tester: Array<Tester>) => void`
1412+
1413+
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`.
1414+
1415+
```ts
1416+
import { expect, test } from 'vitest'
1417+
1418+
class AnagramComparator {
1419+
public word: string
1420+
1421+
constructor(word: string) {
1422+
this.word = word
1423+
}
1424+
1425+
equals(other: AnagramComparator): boolean {
1426+
const cleanStr1 = this.word.replace(/ /g, '').toLowerCase()
1427+
const cleanStr2 = other.word.replace(/ /g, '').toLowerCase()
1428+
1429+
const sortedStr1 = cleanStr1.split('').sort().join('')
1430+
const sortedStr2 = cleanStr2.split('').sort().join('')
1431+
1432+
return sortedStr1 === sortedStr2
1433+
}
1434+
}
1435+
1436+
function isAnagramComparator(a: unknown): a is AnagramComparator {
1437+
return a instanceof AnagramComparator
1438+
}
1439+
1440+
function areAnagramsEqual(a: unknown, b: unknown): boolean | undefined {
1441+
const isAAnagramComparator = isAnagramComparator(a)
1442+
const isBAnagramComparator = isAnagramComparator(b)
1443+
1444+
if (isAAnagramComparator && isBAnagramComparator)
1445+
return a.equals(b)
1446+
1447+
else if (isAAnagramComparator === isBAnagramComparator)
1448+
return undefined
1449+
1450+
else
1451+
return false
1452+
}
1453+
1454+
expect.addEqualityTesters([areAnagramsEqual])
1455+
1456+
test('custom equality tester', () => {
1457+
expect(new AnagramComparator('listen')).toEqual(new AnagramComparator('silent'))
1458+
})
1459+
```

‎packages/expect/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export * from './constants'
44
export * from './types'
55
export { getState, setState } from './state'
66
export { JestChaiExpect } from './jest-expect'
7+
export { addCustomEqualityTesters } from './jest-matcher-utils'
78
export { JestExtend } from './jest-extend'
89
export { setupColors } from '@vitest/utils'

‎packages/expect/src/jest-asymmetric-matchers.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ChaiPlugin, MatcherState } from './types'
22
import { GLOBAL_EXPECT } from './constants'
33
import { getState } from './state'
4-
import { diff, getMatcherUtils, stringify } from './jest-matcher-utils'
4+
import { diff, getCustomEqualityTesters, getMatcherUtils, stringify } from './jest-matcher-utils'
55

66
import { equals, isA, iterableEquality, pluralize, subsetEquality } from './jest-utils'
77

@@ -26,7 +26,7 @@ export abstract class AsymmetricMatcher<
2626
...getState(expect || (globalThis as any)[GLOBAL_EXPECT]),
2727
equals,
2828
isNot: this.inverse,
29-
customTesters: [],
29+
customTesters: getCustomEqualityTesters(),
3030
utils: {
3131
...getMatcherUtils(),
3232
diff,
@@ -116,8 +116,9 @@ export class ObjectContaining extends AsymmetricMatcher<Record<string, unknown>>
116116

117117
let result = true
118118

119+
const matcherContext = this.getMatcherContext()
119120
for (const property in this.sample) {
120-
if (!this.hasProperty(other, property) || !equals(this.sample[property], other[property])) {
121+
if (!this.hasProperty(other, property) || !equals(this.sample[property], other[property], matcherContext.customTesters)) {
121122
result = false
122123
break
123124
}
@@ -149,11 +150,12 @@ export class ArrayContaining<T = unknown> extends AsymmetricMatcher<Array<T>> {
149150
)
150151
}
151152

153+
const matcherContext = this.getMatcherContext()
152154
const result
153155
= this.sample.length === 0
154156
|| (Array.isArray(other)
155157
&& this.sample.every(item =>
156-
other.some(another => equals(item, another)),
158+
other.some(another => equals(item, another, matcherContext.customTesters)),
157159
))
158160

159161
return this.inverse ? !result : result

‎packages/expect/src/jest-expect.ts

+12-9
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Test } from '@vitest/runner'
66
import type { Assertion, ChaiPlugin } from './types'
77
import { arrayBufferEquality, generateToBeMessage, iterableEquality, equals as jestEquals, sparseArrayEquality, subsetEquality, typeEquality } from './jest-utils'
88
import type { AsymmetricMatcher } from './jest-asymmetric-matchers'
9-
import { diff, stringify } from './jest-matcher-utils'
9+
import { diff, getCustomEqualityTesters, stringify } from './jest-matcher-utils'
1010
import { JEST_MATCHERS_OBJECT } from './constants'
1111
import { recordAsyncExpect, wrapSoft } from './utils'
1212

@@ -23,6 +23,7 @@ declare class DOMTokenList {
2323
export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
2424
const { AssertionError } = chai
2525
const c = () => getColors()
26+
const customTesters = getCustomEqualityTesters()
2627

2728
function def(name: keyof Assertion | (keyof Assertion)[], fn: ((this: Chai.AssertionStatic & Assertion, ...args: any[]) => any)) {
2829
const addMethod = (n: keyof Assertion) => {
@@ -80,7 +81,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
8081
const equal = jestEquals(
8182
actual,
8283
expected,
83-
[iterableEquality],
84+
[...customTesters, iterableEquality],
8485
)
8586

8687
return this.assert(
@@ -98,6 +99,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
9899
obj,
99100
expected,
100101
[
102+
...customTesters,
101103
iterableEquality,
102104
typeEquality,
103105
sparseArrayEquality,
@@ -125,6 +127,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
125127
actual,
126128
expected,
127129
[
130+
...customTesters,
128131
iterableEquality,
129132
typeEquality,
130133
sparseArrayEquality,
@@ -140,7 +143,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
140143
const toEqualPass = jestEquals(
141144
actual,
142145
expected,
143-
[iterableEquality],
146+
[...customTesters, iterableEquality],
144147
)
145148

146149
if (toEqualPass)
@@ -159,7 +162,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
159162
def('toMatchObject', function (expected) {
160163
const actual = this._obj
161164
return this.assert(
162-
jestEquals(actual, expected, [iterableEquality, subsetEquality]),
165+
jestEquals(actual, expected, [...customTesters, iterableEquality, subsetEquality]),
163166
'expected #{this} to match object #{exp}',
164167
'expected #{this} to not match object #{exp}',
165168
expected,
@@ -208,7 +211,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
208211
def('toContainEqual', function (expected) {
209212
const obj = utils.flag(this, 'object')
210213
const index = Array.from(obj).findIndex((item) => {
211-
return jestEquals(item, expected)
214+
return jestEquals(item, expected, customTesters)
212215
})
213216

214217
this.assert(
@@ -339,7 +342,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
339342
return utils.getPathInfo(actual, propertyName)
340343
}
341344
const { value, exists } = getValue()
342-
const pass = exists && (args.length === 1 || jestEquals(expected, value))
345+
const pass = exists && (args.length === 1 || jestEquals(expected, value, customTesters))
343346

344347
const valueString = args.length === 1 ? '' : ` with value ${utils.objDisplay(expected)}`
345348

@@ -482,7 +485,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
482485
def(['toHaveBeenCalledWith', 'toBeCalledWith'], function (...args) {
483486
const spy = getSpy(this)
484487
const spyName = spy.getMockName()
485-
const pass = spy.mock.calls.some(callArg => jestEquals(callArg, args, [iterableEquality]))
488+
const pass = spy.mock.calls.some(callArg => jestEquals(callArg, args, [...customTesters, iterableEquality]))
486489
const isNot = utils.flag(this, 'negate') as boolean
487490

488491
const msg = utils.getMessage(
@@ -504,7 +507,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
504507
const nthCall = spy.mock.calls[times - 1]
505508

506509
this.assert(
507-
jestEquals(nthCall, args, [iterableEquality]),
510+
jestEquals(nthCall, args, [...customTesters, iterableEquality]),
508511
`expected ${ordinalOf(times)} "${spyName}" call to have been called with #{exp}`,
509512
`expected ${ordinalOf(times)} "${spyName}" call to not have been called with #{exp}`,
510513
args,
@@ -517,7 +520,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
517520
const lastCall = spy.mock.calls[spy.mock.calls.length - 1]
518521

519522
this.assert(
520-
jestEquals(lastCall, args, [iterableEquality]),
523+
jestEquals(lastCall, args, [...customTesters, iterableEquality]),
521524
`expected last "${spyName}" call to have been called with #{exp}`,
522525
`expected last "${spyName}" call to not have been called with #{exp}`,
523526
args,

‎packages/expect/src/jest-extend.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { ASYMMETRIC_MATCHERS_OBJECT, JEST_MATCHERS_OBJECT } from './constants'
1010
import { AsymmetricMatcher } from './jest-asymmetric-matchers'
1111
import { getState } from './state'
1212

13-
import { diff, getMatcherUtils, stringify } from './jest-matcher-utils'
13+
import { diff, getCustomEqualityTesters, getMatcherUtils, stringify } from './jest-matcher-utils'
1414

1515
import {
1616
equals,
@@ -33,8 +33,7 @@ function getMatcherState(assertion: Chai.AssertionStatic & Chai.Assertion, expec
3333

3434
const matcherState: MatcherState = {
3535
...getState(expect),
36-
// TODO: implement via expect.addEqualityTesters
37-
customTesters: [],
36+
customTesters: getCustomEqualityTesters(),
3837
isNot,
3938
utils: jestUtils,
4039
promise,

‎packages/expect/src/jest-matcher-utils.ts

+21-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { getColors, stringify } from '@vitest/utils'
2-
import type { MatcherHintOptions } from './types'
1+
import { getColors, getType, stringify } from '@vitest/utils'
2+
import type { MatcherHintOptions, Tester } from './types'
3+
import { JEST_MATCHERS_OBJECT } from './constants'
34

45
export { diff } from '@vitest/utils/diff'
56
export { stringify }
@@ -101,3 +102,21 @@ export function getMatcherUtils() {
101102
printExpected,
102103
}
103104
}
105+
106+
export function addCustomEqualityTesters(newTesters: Array<Tester>): void {
107+
if (!Array.isArray(newTesters)) {
108+
throw new TypeError(
109+
`expect.customEqualityTesters: Must be set to an array of Testers. Was given "${getType(
110+
newTesters,
111+
)}"`,
112+
)
113+
}
114+
115+
(globalThis as any)[JEST_MATCHERS_OBJECT].customEqualityTesters.push(
116+
...newTesters,
117+
)
118+
}
119+
120+
export function getCustomEqualityTesters(): Array<Tester> {
121+
return (globalThis as any)[JEST_MATCHERS_OBJECT].customEqualityTesters
122+
}

‎packages/expect/src/jest-utils.ts

+32-20
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
2323
*/
2424

2525
import { isObject } from '@vitest/utils'
26-
import type { Tester } from './types'
26+
import type { Tester, TesterContext } from './types'
2727

2828
// Extracted out of jasmine 2.5.2
2929
export function equals(
@@ -87,8 +87,9 @@ function eq(
8787
if (asymmetricResult !== undefined)
8888
return asymmetricResult
8989

90+
const testerContext: TesterContext = { equals }
9091
for (let i = 0; i < customTesters.length; i++) {
91-
const customTesterResult = customTesters[i](a, b)
92+
const customTesterResult = customTesters[i].call(testerContext, a, b, customTesters)
9293
if (customTesterResult !== undefined)
9394
return customTesterResult
9495
}
@@ -298,7 +299,7 @@ function hasIterator(object: any) {
298299
return !!(object != null && object[IteratorSymbol])
299300
}
300301

301-
export function iterableEquality(a: any, b: any, aStack: Array<any> = [], bStack: Array<any> = []): boolean | undefined {
302+
export function iterableEquality(a: any, b: any, customTesters: Array<Tester> = [], aStack: Array<any> = [], bStack: Array<any> = []): boolean | undefined {
302303
if (
303304
typeof a !== 'object'
304305
|| typeof b !== 'object'
@@ -324,7 +325,20 @@ export function iterableEquality(a: any, b: any, aStack: Array<any> = [], bStack
324325
aStack.push(a)
325326
bStack.push(b)
326327

327-
const iterableEqualityWithStack = (a: any, b: any) => iterableEquality(a, b, [...aStack], [...bStack])
328+
const filteredCustomTesters: Array<Tester> = [
329+
...customTesters.filter(t => t !== iterableEquality),
330+
iterableEqualityWithStack,
331+
]
332+
333+
function iterableEqualityWithStack(a: any, b: any) {
334+
return iterableEquality(
335+
a,
336+
b,
337+
[...filteredCustomTesters],
338+
[...aStack],
339+
[...bStack],
340+
)
341+
}
328342

329343
if (a.size !== undefined) {
330344
if (a.size !== b.size) {
@@ -336,7 +350,7 @@ export function iterableEquality(a: any, b: any, aStack: Array<any> = [], bStack
336350
if (!b.has(aValue)) {
337351
let has = false
338352
for (const bValue of b) {
339-
const isEqual = equals(aValue, bValue, [iterableEqualityWithStack])
353+
const isEqual = equals(aValue, bValue, filteredCustomTesters)
340354
if (isEqual === true)
341355
has = true
342356
}
@@ -357,20 +371,16 @@ export function iterableEquality(a: any, b: any, aStack: Array<any> = [], bStack
357371
for (const aEntry of a) {
358372
if (
359373
!b.has(aEntry[0])
360-
|| !equals(aEntry[1], b.get(aEntry[0]), [iterableEqualityWithStack])
374+
|| !equals(aEntry[1], b.get(aEntry[0]), filteredCustomTesters)
361375
) {
362376
let has = false
363377
for (const bEntry of b) {
364-
const matchedKey = equals(aEntry[0], bEntry[0], [
365-
iterableEqualityWithStack,
366-
])
378+
const matchedKey = equals(aEntry[0], bEntry[0], filteredCustomTesters)
367379

368380
let matchedValue = false
369-
if (matchedKey === true) {
370-
matchedValue = equals(aEntry[1], bEntry[1], [
371-
iterableEqualityWithStack,
372-
])
373-
}
381+
if (matchedKey === true)
382+
matchedValue = equals(aEntry[1], bEntry[1], filteredCustomTesters)
383+
374384
if (matchedValue === true)
375385
has = true
376386
}
@@ -394,7 +404,7 @@ export function iterableEquality(a: any, b: any, aStack: Array<any> = [], bStack
394404
const nextB = bIterator.next()
395405
if (
396406
nextB.done
397-
|| !equals(aValue, nextB.value, [iterableEqualityWithStack])
407+
|| !equals(aValue, nextB.value, filteredCustomTesters)
398408
)
399409
return false
400410
}
@@ -430,7 +440,8 @@ function isObjectWithKeys(a: any) {
430440
&& !(a instanceof Date)
431441
}
432442

433-
export function subsetEquality(object: unknown, subset: unknown): boolean | undefined {
443+
export function subsetEquality(object: unknown, subset: unknown, customTesters: Array<Tester> = []): boolean | undefined {
444+
const filteredCustomTesters = customTesters.filter(t => t !== subsetEquality)
434445
// subsetEquality needs to keep track of the references
435446
// it has already visited to avoid infinite loops in case
436447
// there are circular references in the subset passed to it.
@@ -443,15 +454,15 @@ export function subsetEquality(object: unknown, subset: unknown): boolean | unde
443454
return Object.keys(subset).every((key) => {
444455
if (isObjectWithKeys(subset[key])) {
445456
if (seenReferences.has(subset[key]))
446-
return equals(object[key], subset[key], [iterableEquality])
457+
return equals(object[key], subset[key], filteredCustomTesters)
447458

448459
seenReferences.set(subset[key], true)
449460
}
450461
const result
451462
= object != null
452463
&& hasPropertyInObject(object, key)
453464
&& equals(object[key], subset[key], [
454-
iterableEquality,
465+
...filteredCustomTesters,
455466
subsetEqualityWithContext(seenReferences),
456467
])
457468
// 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
504515
return true
505516
}
506517

507-
export function sparseArrayEquality(a: unknown, b: unknown): boolean | undefined {
518+
export function sparseArrayEquality(a: unknown, b: unknown, customTesters: Array<Tester> = []): boolean | undefined {
508519
if (!Array.isArray(a) || !Array.isArray(b))
509520
return undefined
510521

511522
// A sparse array [, , 1] will have keys ["2"] whereas [undefined, undefined, 1] will have keys ["0", "1", "2"]
512523
const aKeys = Object.keys(a)
513524
const bKeys = Object.keys(b)
525+
const filteredCustomTesters = customTesters.filter(t => t !== sparseArrayEquality)
514526
return (
515-
equals(a, b, [iterableEquality, typeEquality], true) && equals(aKeys, bKeys)
527+
equals(a, b, filteredCustomTesters, true) && equals(aKeys, bKeys)
516528
)
517529
}
518530

‎packages/expect/src/state.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type { ExpectStatic, MatcherState } from './types'
1+
import type { ExpectStatic, MatcherState, Tester } from './types'
22
import { ASYMMETRIC_MATCHERS_OBJECT, GLOBAL_EXPECT, JEST_MATCHERS_OBJECT, MATCHERS_OBJECT } from './constants'
33

44
if (!Object.prototype.hasOwnProperty.call(globalThis, MATCHERS_OBJECT)) {
55
const globalState = new WeakMap<ExpectStatic, MatcherState>()
66
const matchers = Object.create(null)
7+
const customEqualityTesters: Array<Tester> = []
78
const assymetricMatchers = Object.create(null)
89
Object.defineProperty(globalThis, MATCHERS_OBJECT, {
910
get: () => globalState,
@@ -13,6 +14,7 @@ if (!Object.prototype.hasOwnProperty.call(globalThis, MATCHERS_OBJECT)) {
1314
get: () => ({
1415
state: globalState.get((globalThis as any)[GLOBAL_EXPECT]),
1516
matchers,
17+
customEqualityTesters,
1618
}),
1719
})
1820
Object.defineProperty(globalThis, ASYMMETRIC_MATCHERS_OBJECT, {

‎packages/expect/src/types.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,21 @@ import type { diff, getMatcherUtils, stringify } from './jest-matcher-utils'
1212

1313
export type ChaiPlugin = Chai.ChaiPlugin
1414

15-
export type Tester = (a: any, b: any) => boolean | undefined
16-
15+
export type Tester = (
16+
this: TesterContext,
17+
a: any,
18+
b: any,
19+
customTesters: Array<Tester>,
20+
) => boolean | undefined
21+
22+
export interface TesterContext {
23+
equals: (
24+
a: unknown,
25+
b: unknown,
26+
customTesters?: Array<Tester>,
27+
strictCheck?: boolean,
28+
) => boolean
29+
}
1730
export type { DiffOptions } from '@vitest/utils/diff'
1831

1932
export interface MatcherHintOptions {
@@ -81,6 +94,7 @@ export interface ExpectStatic extends Chai.ExpectStatic, AsymmetricMatchersConta
8194
unreachable(message?: string): never
8295
soft<T>(actual: T, message?: string): Assertion<T>
8396
extend(expects: MatchersObject): void
97+
addEqualityTesters(testers: Array<Tester>): void
8498
assertions(expected: number): void
8599
hasAssertions(): void
86100
anything(): any

‎packages/vitest/src/integrations/chai/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as chai from 'chai'
44
import './setup'
55
import type { TaskPopulated, Test } from '@vitest/runner'
66
import { getCurrentTest } from '@vitest/runner'
7-
import { ASYMMETRIC_MATCHERS_OBJECT, GLOBAL_EXPECT, getState, setState } from '@vitest/expect'
7+
import { ASYMMETRIC_MATCHERS_OBJECT, GLOBAL_EXPECT, addCustomEqualityTesters, getState, setState } from '@vitest/expect'
88
import type { Assertion, ExpectStatic } from '@vitest/expect'
99
import type { MatcherState } from '../../types/chai'
1010
import { getFullName } from '../../utils/tasks'
@@ -46,6 +46,8 @@ export function createExpect(test?: TaskPopulated) {
4646

4747
// @ts-expect-error untyped
4848
expect.extend = matchers => chai.expect.extend(expect, matchers)
49+
expect.addEqualityTesters = customTesters =>
50+
addCustomEqualityTesters(customTesters)
4951

5052
expect.soft = (...args) => {
5153
const assert = expect(...args)

‎test/core/test/expect.test.ts

+232-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import type { Tester } from '@vitest/expect'
12
import { getCurrentTest } from '@vitest/runner'
2-
import { describe, expect, expectTypeOf, test } from 'vitest'
3+
import { describe, expect, expectTypeOf, test, vi } from 'vitest'
34

45
describe('expect.soft', () => {
56
test('types', () => {
@@ -40,3 +41,233 @@ describe('expect.soft', () => {
4041
expect.soft('test3').toBe('test res')
4142
})
4243
})
44+
45+
describe('expect.addEqualityTesters', () => {
46+
class AnagramComparator {
47+
public word: string
48+
49+
constructor(word: string) {
50+
this.word = word
51+
}
52+
53+
equals(other: AnagramComparator): boolean {
54+
const cleanStr1 = this.word.replace(/ /g, '').toLowerCase()
55+
const cleanStr2 = other.word.replace(/ /g, '').toLowerCase()
56+
57+
const sortedStr1 = cleanStr1.split('').sort().join('')
58+
const sortedStr2 = cleanStr2.split('').sort().join('')
59+
60+
return sortedStr1 === sortedStr2
61+
}
62+
}
63+
64+
function createAnagramComparator(word: string) {
65+
return new AnagramComparator(word)
66+
}
67+
68+
function isAnagramComparator(a: unknown): a is AnagramComparator {
69+
return a instanceof AnagramComparator
70+
}
71+
72+
const areObjectsEqual: Tester = (
73+
a: unknown,
74+
b: unknown,
75+
): boolean | undefined => {
76+
const isAAnagramComparator = isAnagramComparator(a)
77+
const isBAnagramComparator = isAnagramComparator(b)
78+
79+
if (isAAnagramComparator && isBAnagramComparator)
80+
return a.equals(b)
81+
82+
else if (isAAnagramComparator === isBAnagramComparator)
83+
return undefined
84+
85+
else
86+
return false
87+
}
88+
89+
function* toIterator<T>(array: Array<T>): Iterator<T> {
90+
for (const obj of array)
91+
yield obj
92+
}
93+
94+
const customObject1 = createAnagramComparator('listen')
95+
const customObject2 = createAnagramComparator('silent')
96+
97+
expect.addEqualityTesters([areObjectsEqual])
98+
99+
test('AnagramComparator objects are unique and not contained within arrays of AnagramComparator objects', () => {
100+
expect(customObject1).not.toBe(customObject2)
101+
expect([customObject1]).not.toContain(customObject2)
102+
})
103+
104+
test('basic matchers pass different AnagramComparator objects', () => {
105+
expect(customObject1).toEqual(customObject2)
106+
expect([customObject1, customObject2]).toEqual([customObject2, customObject1])
107+
expect(new Map([['key', customObject1]])).toEqual(new Map([['key', customObject2]]))
108+
expect(new Set([customObject1])).toEqual(new Set([customObject2]))
109+
expect(toIterator([customObject1, customObject2])).toEqual(
110+
toIterator([customObject2, customObject1]),
111+
)
112+
expect([customObject1]).toContainEqual(customObject2)
113+
expect({ a: customObject1 }).toHaveProperty('a', customObject2)
114+
expect({ a: customObject2, b: undefined }).toStrictEqual({
115+
a: customObject1,
116+
b: undefined,
117+
})
118+
expect({ a: 1, b: { c: customObject1 } }).toMatchObject({
119+
a: 1,
120+
b: { c: customObject2 },
121+
})
122+
})
123+
124+
test('asymmetric matchers pass different AnagramComparator objects', () => {
125+
expect([customObject1]).toEqual(expect.arrayContaining([customObject1]))
126+
expect({ a: 1, b: { c: customObject1 } }).toEqual(
127+
expect.objectContaining({ b: { c: customObject2 } }),
128+
)
129+
})
130+
131+
test('toBe recommends toStrictEqual even with different objects', () => {
132+
expect(() => expect(customObject1).toBe(customObject2)).toThrow('toStrictEqual')
133+
})
134+
135+
test('toBe recommends toEqual even with different AnagramComparator objects', () => {
136+
expect(() => expect({ a: undefined, b: customObject1 }).toBe({ b: customObject2 })).toThrow(
137+
'toEqual',
138+
)
139+
})
140+
141+
test('iterableEquality still properly detects cycles', () => {
142+
const a = new Set()
143+
a.add(customObject1)
144+
a.add(a)
145+
146+
const b = new Set()
147+
b.add(customObject2)
148+
b.add(b)
149+
150+
expect(a).toEqual(b)
151+
})
152+
})
153+
154+
describe('recursive custom equality tester', () => {
155+
let personId = 0
156+
157+
class Address {
158+
public address: string
159+
160+
constructor(address: string) {
161+
this.address = address
162+
}
163+
}
164+
class Person {
165+
public name: string
166+
public address: Address
167+
public personId: string
168+
169+
constructor(name: string, address: Address) {
170+
this.name = name
171+
this.address = address
172+
this.personId = `${personId++}`
173+
}
174+
}
175+
176+
const arePersonsEqual: Tester = function (a, b, customTesters) {
177+
const isAPerson = a instanceof Person
178+
const isBPerson = b instanceof Person
179+
180+
if (isAPerson && isBPerson)
181+
return a.name === b.name && this.equals(a.address, b.address, customTesters)
182+
183+
else if (isAPerson === isBPerson)
184+
return undefined
185+
186+
else
187+
return false
188+
}
189+
190+
const areAddressesEqual: Tester = (a, b) => {
191+
const isAAddress = a instanceof Address
192+
const isBAddress = b instanceof Address
193+
194+
if (isAAddress && isBAddress)
195+
return a.address === b.address
196+
197+
else if (isAAddress === isBAddress)
198+
return undefined
199+
200+
else
201+
return false
202+
}
203+
204+
const person1 = new Person('Luke Skywalker', new Address('Tatooine'))
205+
const person2 = new Person('Luke Skywalker', new Address('Tatooine'))
206+
207+
expect.addEqualityTesters([areAddressesEqual, arePersonsEqual])
208+
209+
test('basic matchers pass different Address objects', () => {
210+
expect(person1).not.toBe(person2)
211+
expect([person1]).not.toContain(person2)
212+
expect(person1).toEqual(person1)
213+
expect(person1).toEqual(person2)
214+
expect([person1, person2]).toEqual([person2, person1])
215+
expect(new Map([['key', person1]])).toEqual(new Map([['key', person2]]))
216+
expect(new Set([person1])).toEqual(new Set([person2]))
217+
expect([person1]).toContainEqual(person2)
218+
expect({ a: person1 }).toHaveProperty('a', person2)
219+
expect({ a: person1, b: undefined }).toStrictEqual({
220+
a: person2,
221+
b: undefined,
222+
})
223+
expect({ a: 1, b: { c: person1 } }).toMatchObject({
224+
a: 1,
225+
b: { c: person2 },
226+
})
227+
})
228+
229+
test('asymmetric matchers pass different Address objects', () => {
230+
expect([person1]).toEqual(expect.arrayContaining([person2]))
231+
expect({ a: 1, b: { c: person1 } }).toEqual(
232+
expect.objectContaining({ b: { c: person2 } }),
233+
)
234+
})
235+
236+
test('toBe recommends toStrictEqual even with different Address objects', () => {
237+
expect(() => expect(person1).toBe(person2)).toThrow('toStrictEqual')
238+
})
239+
240+
test('toBe recommends toEqual even with different Address objects', () => {
241+
expect(() => expect({ a: undefined, b: person1 }).toBe({ b: person2 })).toThrow(
242+
'toEqual',
243+
)
244+
})
245+
246+
test('iterableEquality still properly detects cycles', () => {
247+
const a = new Set()
248+
a.add(person1)
249+
a.add(a)
250+
251+
const b = new Set()
252+
b.add(person2)
253+
b.add(b)
254+
255+
expect(a).toEqual(b)
256+
})
257+
258+
test('spy matchers pass different Person objects', () => {
259+
const mockFn = vi.fn(
260+
(person: Person) => [person, person2],
261+
)
262+
mockFn(person1)
263+
264+
expect(mockFn).toHaveBeenCalledWith(person1)
265+
expect(mockFn).toHaveBeenCalledWith(person1)
266+
expect(mockFn).toHaveBeenLastCalledWith(person1)
267+
expect(mockFn).toHaveBeenNthCalledWith(1, person1)
268+
269+
expect(mockFn).toHaveReturnedWith([person1, person2])
270+
expect(mockFn).toHaveLastReturnedWith([person1, person2])
271+
expect(mockFn).toHaveNthReturnedWith(1, [person1, person2])
272+
})
273+
})

0 commit comments

Comments
 (0)
Please sign in to comment.