Skip to content

Commit

Permalink
feat: remove unrelated noise from diff for toMatchObject() (#5364)
Browse files Browse the repository at this point in the history
  • Loading branch information
geersch committed Apr 8, 2024
1 parent 20357e2 commit 9927639
Show file tree
Hide file tree
Showing 3 changed files with 227 additions and 23 deletions.
24 changes: 17 additions & 7 deletions packages/expect/src/jest-expect.ts
Expand Up @@ -4,7 +4,7 @@ import type { MockInstance } from '@vitest/spy'
import { isMockFunction } from '@vitest/spy'
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 { arrayBufferEquality, generateToBeMessage, getObjectSubset, iterableEquality, equals as jestEquals, sparseArrayEquality, subsetEquality, typeEquality } from './jest-utils'
import type { AsymmetricMatcher } from './jest-asymmetric-matchers'
import { diff, getCustomEqualityTesters, stringify } from './jest-matcher-utils'
import { JEST_MATCHERS_OBJECT } from './constants'
Expand Down Expand Up @@ -161,13 +161,23 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
})
def('toMatchObject', function (expected) {
const actual = this._obj
return this.assert(
jestEquals(actual, expected, [...customTesters, iterableEquality, subsetEquality]),
'expected #{this} to match object #{exp}',
'expected #{this} to not match object #{exp}',
expected,
actual,
const pass = jestEquals(actual, expected, [...customTesters, iterableEquality, subsetEquality])
const isNot = utils.flag(this, 'negate') as boolean
const { subset: actualSubset, stripped } = getObjectSubset(actual, expected)
const msg = utils.getMessage(
this,
[
pass,
'expected #{this} to match object #{exp}',
'expected #{this} to not match object #{exp}',
expected,
actualSubset,
],
)
if ((pass && isNot) || (!pass && !isNot)) {
const message = stripped === 0 ? msg : `${msg}\n(${stripped} matching ${stripped === 1 ? 'property' : 'properties'} omitted from actual)`
throw new AssertionError(message, { showDiff: true, expected, actual: actualSubset })
}
})
def('toMatch', function (expected: string | RegExp) {
const actual = this._obj as string
Expand Down
68 changes: 67 additions & 1 deletion packages/expect/src/jest-utils.ts
Expand Up @@ -420,7 +420,7 @@ export function iterableEquality(a: any, b: any, customTesters: Array<Tester> =
/**
* Checks if `hasOwnProperty(object, key)` up the prototype chain, stopping at `Object.prototype`.
*/
function hasPropertyInObject(object: object, key: string): boolean {
function hasPropertyInObject(object: object, key: string | symbol): boolean {
const shouldTerminate
= !object || typeof object !== 'object' || object === Object.prototype

Expand Down Expand Up @@ -540,3 +540,69 @@ export function generateToBeMessage(deepEqualityName: string, expected = '#{this
export function pluralize(word: string, count: number): string {
return `${count} ${word}${count === 1 ? '' : 's'}`
}

export function getObjectKeys(object: object): Array<string | symbol> {
return [
...Object.keys(object),
...Object.getOwnPropertySymbols(object).filter(
s => Object.getOwnPropertyDescriptor(object, s)?.enumerable,
),
]
}

export function getObjectSubset(object: any, subset: any, customTesters: Array<Tester> = []): { subset: any; stripped: number } {
let stripped = 0

const getObjectSubsetWithContext = (seenReferences: WeakMap<object, boolean> = new WeakMap()) => (object: any, subset: any): any => {
if (Array.isArray(object)) {
if (Array.isArray(subset) && subset.length === object.length) {
// The map method returns correct subclass of subset.
return subset.map((sub: any, i: number) =>
getObjectSubsetWithContext(seenReferences)(object[i], sub),
)
}
}
else if (object instanceof Date) {
return object
}
else if (isObject(object) && isObject(subset)) {
if (
equals(object, subset, [
...customTesters,
iterableEquality,
subsetEquality,
])
) {
// Avoid unnecessary copy which might return Object instead of subclass.
return subset
}

const trimmed: any = {}
seenReferences.set(object, trimmed)

for (const key of getObjectKeys(object)) {
if (hasPropertyInObject(subset, key)) {
trimmed[key] = seenReferences.has(object[key])
? seenReferences.get(object[key])
: getObjectSubsetWithContext(seenReferences)(object[key], subset[key])
}
else {
if (!seenReferences.has(object[key])) {
stripped += 1
if (isObject(object[key]))
stripped += getObjectKeys(object[key]).length

getObjectSubsetWithContext(seenReferences)(object[key], subset[key])
}
}
}

if (getObjectKeys(trimmed).length > 0)
return trimmed
}

return object
}

return { subset: getObjectSubsetWithContext()(object, subset), stripped }
}
158 changes: 143 additions & 15 deletions test/core/test/jest-expect.test.ts
Expand Up @@ -903,24 +903,152 @@ it('correctly prints diff with asymmetric matchers', () => {
}
})

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

// make it easy for dev who trims trailing whitespace on IDE
function trim(s: string): string {
return s.replaceAll(/ *$/gm, '')
function getError(f: () => unknown) {
try {
f()
return expect.unreachable()
}

function getError(f: () => unknown) {
try {
f()
return expect.unreachable()
}
catch (error) {
const processed = processError(error)
return [processed.message, trim(processed.diff)]
}
catch (error) {
const processed = processError(error)
return [processed.message, trim(processed.diff)]
}
}

it('toMatchObject error diff', () => {
setupColors(getDefaultColors())

// single property on root (3 total properties, 1 expected)
expect(getError(() => expect({ a: 1, b: 2, c: 3 }).toMatchObject({ c: 4 }))).toMatchInlineSnapshot(`
[
"expected { a: 1, b: 2, c: 3 } to match object { c: 4 }
(2 matching properties omitted from actual)",
"- Expected
+ Received
Object {
- "c": 4,
+ "c": 3,
}",
]
`)

// single property on root (4 total properties, 1 expected)
expect(getError(() => expect({ a: 1, b: 2, c: { d: 4 } }).toMatchObject({ b: 3 }))).toMatchInlineSnapshot(`
[
"expected { a: 1, b: 2, c: { d: 4 } } to match object { b: 3 }
(3 matching properties omitted from actual)",
"- Expected
+ Received
Object {
- "b": 3,
+ "b": 2,
}",
]
`)

// nested property (7 total properties, 2 expected)
expect(getError(() => expect({ a: 1, b: 2, c: { d: 4, e: 5 }, f: { g: 6 } }).toMatchObject({ c: { d: 5 } }))).toMatchInlineSnapshot(`
[
"expected { a: 1, b: 2, c: { d: 4, e: 5 }, …(1) } to match object { c: { d: 5 } }
(5 matching properties omitted from actual)",
"- Expected
+ Received
Object {
"c": Object {
- "d": 5,
+ "d": 4,
},
}",
]
`)

// 3 total properties, 3 expected (0 stripped)
expect(getError(() => expect({ a: 1, b: 2, c: 3 }).toMatchObject({ a: 1, b: 2, c: 4 }))).toMatchInlineSnapshot(`
[
"expected { a: 1, b: 2, c: 3 } to match object { a: 1, b: 2, c: 4 }",
"- Expected
+ Received
Object {
"a": 1,
"b": 2,
- "c": 4,
+ "c": 3,
}",
]
`)

// 4 total properties, 3 expected
expect(getError(() => expect({ a: 1, b: 2, c: { d: 3 } }).toMatchObject({ a: 1, c: { d: 4 } }))).toMatchInlineSnapshot(`
[
"expected { a: 1, b: 2, c: { d: 3 } } to match object { a: 1, c: { d: 4 } }
(1 matching property omitted from actual)",
"- Expected
+ Received
Object {
"a": 1,
"c": Object {
- "d": 4,
+ "d": 3,
},
}",
]
`)

// 8 total properties, 4 expected
expect(getError(() => expect({ a: 1, b: 2, c: { d: 4 }, foo: { value: 'bar' }, bar: { value: 'foo' } }).toMatchObject({ c: { d: 5 }, foo: { value: 'biz' } }))).toMatchInlineSnapshot(`
[
"expected { a: 1, b: 2, c: { d: 4 }, …(2) } to match object { c: { d: 5 }, foo: { value: 'biz' } }
(4 matching properties omitted from actual)",
"- Expected
+ Received
Object {
"c": Object {
- "d": 5,
+ "d": 4,
},
"foo": Object {
- "value": "biz",
+ "value": "bar",
},
}",
]
`)

// 8 total properties, 3 expected
const characters = { firstName: 'Vladimir', lastName: 'Harkonnen', family: 'House Harkonnen', colors: ['red', 'blue'], children: [{ firstName: 'Jessica', lastName: 'Atreides', colors: ['red', 'green', 'black'] }] }
expect(getError(() => expect(characters).toMatchObject({ family: 'House Atreides', children: [{ firstName: 'Paul' }] }))).toMatchInlineSnapshot(`
[
"expected { firstName: 'Vladimir', …(4) } to match object { family: 'House Atreides', …(1) }
(5 matching properties omitted from actual)",
"- Expected
+ Received
Object {
"children": Array [
Object {
- "firstName": "Paul",
+ "firstName": "Jessica",
},
],
- "family": "House Atreides",
+ "family": "House Harkonnen",
}",
]
`)
})

it('toHaveProperty error diff', () => {
setupColors(getDefaultColors())

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

0 comments on commit 9927639

Please sign in to comment.