Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: remove unrelated noise from diff for toMatchObject() #5364

Merged
merged 4 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/expect/src/jest-asymmetric-matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ class CloseTo extends AsymmetricMatcher<number> {
return [
this.toString(),
this.sample,
`(${pluralize('digit', this.precision)})`,
`(${this.precision} ${pluralize('digit', this.precision)})`,
].join(' ')
}
}
Expand Down
24 changes: 17 additions & 7 deletions packages/expect/src/jest-expect.ts
Original file line number Diff line number Diff line change
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, pluralize, 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 ${pluralize('property', stripped)} 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
74 changes: 72 additions & 2 deletions packages/expect/src/jest-utils.ts
Original file line number Diff line number Diff line change
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 @@ -537,6 +537,76 @@ export function generateToBeMessage(deepEqualityName: string, expected = '#{this
return toBeMessage
}

const IRREGULAR_PLURALS: { [key: string]: string } = {
property: 'properties',
}

export function pluralize(word: string, count: number): string {
return `${count} ${word}${count === 1 ? '' : 's'}`
return count === 1 ? word : IRREGULAR_PLURALS[word] ?? `${word}s`
sheremet-va marked this conversation as resolved.
Show resolved Hide resolved
}

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
Original file line number Diff line number Diff line change
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