Skip to content

Commit 9927639

Browse files
authoredApr 8, 2024··
feat: remove unrelated noise from diff for toMatchObject() (#5364)
1 parent 20357e2 commit 9927639

File tree

3 files changed

+227
-23
lines changed

3 files changed

+227
-23
lines changed
 

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

+17-7
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { MockInstance } from '@vitest/spy'
44
import { isMockFunction } from '@vitest/spy'
55
import type { Test } from '@vitest/runner'
66
import type { Assertion, ChaiPlugin } from './types'
7-
import { arrayBufferEquality, generateToBeMessage, iterableEquality, equals as jestEquals, sparseArrayEquality, subsetEquality, typeEquality } from './jest-utils'
7+
import { arrayBufferEquality, generateToBeMessage, getObjectSubset, iterableEquality, equals as jestEquals, sparseArrayEquality, subsetEquality, typeEquality } from './jest-utils'
88
import type { AsymmetricMatcher } from './jest-asymmetric-matchers'
99
import { diff, getCustomEqualityTesters, stringify } from './jest-matcher-utils'
1010
import { JEST_MATCHERS_OBJECT } from './constants'
@@ -161,13 +161,23 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
161161
})
162162
def('toMatchObject', function (expected) {
163163
const actual = this._obj
164-
return this.assert(
165-
jestEquals(actual, expected, [...customTesters, iterableEquality, subsetEquality]),
166-
'expected #{this} to match object #{exp}',
167-
'expected #{this} to not match object #{exp}',
168-
expected,
169-
actual,
164+
const pass = jestEquals(actual, expected, [...customTesters, iterableEquality, subsetEquality])
165+
const isNot = utils.flag(this, 'negate') as boolean
166+
const { subset: actualSubset, stripped } = getObjectSubset(actual, expected)
167+
const msg = utils.getMessage(
168+
this,
169+
[
170+
pass,
171+
'expected #{this} to match object #{exp}',
172+
'expected #{this} to not match object #{exp}',
173+
expected,
174+
actualSubset,
175+
],
170176
)
177+
if ((pass && isNot) || (!pass && !isNot)) {
178+
const message = stripped === 0 ? msg : `${msg}\n(${stripped} matching ${stripped === 1 ? 'property' : 'properties'} omitted from actual)`
179+
throw new AssertionError(message, { showDiff: true, expected, actual: actualSubset })
180+
}
171181
})
172182
def('toMatch', function (expected: string | RegExp) {
173183
const actual = this._obj as string

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

+67-1
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ export function iterableEquality(a: any, b: any, customTesters: Array<Tester> =
420420
/**
421421
* Checks if `hasOwnProperty(object, key)` up the prototype chain, stopping at `Object.prototype`.
422422
*/
423-
function hasPropertyInObject(object: object, key: string): boolean {
423+
function hasPropertyInObject(object: object, key: string | symbol): boolean {
424424
const shouldTerminate
425425
= !object || typeof object !== 'object' || object === Object.prototype
426426

@@ -540,3 +540,69 @@ export function generateToBeMessage(deepEqualityName: string, expected = '#{this
540540
export function pluralize(word: string, count: number): string {
541541
return `${count} ${word}${count === 1 ? '' : 's'}`
542542
}
543+
544+
export function getObjectKeys(object: object): Array<string | symbol> {
545+
return [
546+
...Object.keys(object),
547+
...Object.getOwnPropertySymbols(object).filter(
548+
s => Object.getOwnPropertyDescriptor(object, s)?.enumerable,
549+
),
550+
]
551+
}
552+
553+
export function getObjectSubset(object: any, subset: any, customTesters: Array<Tester> = []): { subset: any; stripped: number } {
554+
let stripped = 0
555+
556+
const getObjectSubsetWithContext = (seenReferences: WeakMap<object, boolean> = new WeakMap()) => (object: any, subset: any): any => {
557+
if (Array.isArray(object)) {
558+
if (Array.isArray(subset) && subset.length === object.length) {
559+
// The map method returns correct subclass of subset.
560+
return subset.map((sub: any, i: number) =>
561+
getObjectSubsetWithContext(seenReferences)(object[i], sub),
562+
)
563+
}
564+
}
565+
else if (object instanceof Date) {
566+
return object
567+
}
568+
else if (isObject(object) && isObject(subset)) {
569+
if (
570+
equals(object, subset, [
571+
...customTesters,
572+
iterableEquality,
573+
subsetEquality,
574+
])
575+
) {
576+
// Avoid unnecessary copy which might return Object instead of subclass.
577+
return subset
578+
}
579+
580+
const trimmed: any = {}
581+
seenReferences.set(object, trimmed)
582+
583+
for (const key of getObjectKeys(object)) {
584+
if (hasPropertyInObject(subset, key)) {
585+
trimmed[key] = seenReferences.has(object[key])
586+
? seenReferences.get(object[key])
587+
: getObjectSubsetWithContext(seenReferences)(object[key], subset[key])
588+
}
589+
else {
590+
if (!seenReferences.has(object[key])) {
591+
stripped += 1
592+
if (isObject(object[key]))
593+
stripped += getObjectKeys(object[key]).length
594+
595+
getObjectSubsetWithContext(seenReferences)(object[key], subset[key])
596+
}
597+
}
598+
}
599+
600+
if (getObjectKeys(trimmed).length > 0)
601+
return trimmed
602+
}
603+
604+
return object
605+
}
606+
607+
return { subset: getObjectSubsetWithContext()(object, subset), stripped }
608+
}

‎test/core/test/jest-expect.test.ts

+143-15
Original file line numberDiff line numberDiff line change
@@ -903,24 +903,152 @@ it('correctly prints diff with asymmetric matchers', () => {
903903
}
904904
})
905905

906-
it('toHaveProperty error diff', () => {
907-
setupColors(getDefaultColors())
906+
// make it easy for dev who trims trailing whitespace on IDE
907+
function trim(s: string): string {
908+
return s.replaceAll(/ *$/gm, '')
909+
}
908910

909-
// make it easy for dev who trims trailing whitespace on IDE
910-
function trim(s: string): string {
911-
return s.replaceAll(/ *$/gm, '')
911+
function getError(f: () => unknown) {
912+
try {
913+
f()
914+
return expect.unreachable()
912915
}
913-
914-
function getError(f: () => unknown) {
915-
try {
916-
f()
917-
return expect.unreachable()
918-
}
919-
catch (error) {
920-
const processed = processError(error)
921-
return [processed.message, trim(processed.diff)]
922-
}
916+
catch (error) {
917+
const processed = processError(error)
918+
return [processed.message, trim(processed.diff)]
923919
}
920+
}
921+
922+
it('toMatchObject error diff', () => {
923+
setupColors(getDefaultColors())
924+
925+
// single property on root (3 total properties, 1 expected)
926+
expect(getError(() => expect({ a: 1, b: 2, c: 3 }).toMatchObject({ c: 4 }))).toMatchInlineSnapshot(`
927+
[
928+
"expected { a: 1, b: 2, c: 3 } to match object { c: 4 }
929+
(2 matching properties omitted from actual)",
930+
"- Expected
931+
+ Received
932+
933+
Object {
934+
- "c": 4,
935+
+ "c": 3,
936+
}",
937+
]
938+
`)
939+
940+
// single property on root (4 total properties, 1 expected)
941+
expect(getError(() => expect({ a: 1, b: 2, c: { d: 4 } }).toMatchObject({ b: 3 }))).toMatchInlineSnapshot(`
942+
[
943+
"expected { a: 1, b: 2, c: { d: 4 } } to match object { b: 3 }
944+
(3 matching properties omitted from actual)",
945+
"- Expected
946+
+ Received
947+
948+
Object {
949+
- "b": 3,
950+
+ "b": 2,
951+
}",
952+
]
953+
`)
954+
955+
// nested property (7 total properties, 2 expected)
956+
expect(getError(() => expect({ a: 1, b: 2, c: { d: 4, e: 5 }, f: { g: 6 } }).toMatchObject({ c: { d: 5 } }))).toMatchInlineSnapshot(`
957+
[
958+
"expected { a: 1, b: 2, c: { d: 4, e: 5 }, …(1) } to match object { c: { d: 5 } }
959+
(5 matching properties omitted from actual)",
960+
"- Expected
961+
+ Received
962+
963+
Object {
964+
"c": Object {
965+
- "d": 5,
966+
+ "d": 4,
967+
},
968+
}",
969+
]
970+
`)
971+
972+
// 3 total properties, 3 expected (0 stripped)
973+
expect(getError(() => expect({ a: 1, b: 2, c: 3 }).toMatchObject({ a: 1, b: 2, c: 4 }))).toMatchInlineSnapshot(`
974+
[
975+
"expected { a: 1, b: 2, c: 3 } to match object { a: 1, b: 2, c: 4 }",
976+
"- Expected
977+
+ Received
978+
979+
Object {
980+
"a": 1,
981+
"b": 2,
982+
- "c": 4,
983+
+ "c": 3,
984+
}",
985+
]
986+
`)
987+
988+
// 4 total properties, 3 expected
989+
expect(getError(() => expect({ a: 1, b: 2, c: { d: 3 } }).toMatchObject({ a: 1, c: { d: 4 } }))).toMatchInlineSnapshot(`
990+
[
991+
"expected { a: 1, b: 2, c: { d: 3 } } to match object { a: 1, c: { d: 4 } }
992+
(1 matching property omitted from actual)",
993+
"- Expected
994+
+ Received
995+
996+
Object {
997+
"a": 1,
998+
"c": Object {
999+
- "d": 4,
1000+
+ "d": 3,
1001+
},
1002+
}",
1003+
]
1004+
`)
1005+
1006+
// 8 total properties, 4 expected
1007+
expect(getError(() => expect({ a: 1, b: 2, c: { d: 4 }, foo: { value: 'bar' }, bar: { value: 'foo' } }).toMatchObject({ c: { d: 5 }, foo: { value: 'biz' } }))).toMatchInlineSnapshot(`
1008+
[
1009+
"expected { a: 1, b: 2, c: { d: 4 }, …(2) } to match object { c: { d: 5 }, foo: { value: 'biz' } }
1010+
(4 matching properties omitted from actual)",
1011+
"- Expected
1012+
+ Received
1013+
1014+
Object {
1015+
"c": Object {
1016+
- "d": 5,
1017+
+ "d": 4,
1018+
},
1019+
"foo": Object {
1020+
- "value": "biz",
1021+
+ "value": "bar",
1022+
},
1023+
}",
1024+
]
1025+
`)
1026+
1027+
// 8 total properties, 3 expected
1028+
const characters = { firstName: 'Vladimir', lastName: 'Harkonnen', family: 'House Harkonnen', colors: ['red', 'blue'], children: [{ firstName: 'Jessica', lastName: 'Atreides', colors: ['red', 'green', 'black'] }] }
1029+
expect(getError(() => expect(characters).toMatchObject({ family: 'House Atreides', children: [{ firstName: 'Paul' }] }))).toMatchInlineSnapshot(`
1030+
[
1031+
"expected { firstName: 'Vladimir', …(4) } to match object { family: 'House Atreides', …(1) }
1032+
(5 matching properties omitted from actual)",
1033+
"- Expected
1034+
+ Received
1035+
1036+
Object {
1037+
"children": Array [
1038+
Object {
1039+
- "firstName": "Paul",
1040+
+ "firstName": "Jessica",
1041+
},
1042+
],
1043+
- "family": "House Atreides",
1044+
+ "family": "House Harkonnen",
1045+
}",
1046+
]
1047+
`)
1048+
})
1049+
1050+
it('toHaveProperty error diff', () => {
1051+
setupColors(getDefaultColors())
9241052

9251053
// non match value
9261054
expect(getError(() => expect({ name: 'foo' }).toHaveProperty('name', 'bar'))).toMatchInlineSnapshot(`

0 commit comments

Comments
 (0)
Please sign in to comment.