Skip to content

Commit

Permalink
feat: display number of properties omitted from actual in diff for to…
Browse files Browse the repository at this point in the history
…MatchObject matcher
  • Loading branch information
geersch committed Mar 16, 2024
1 parent e4ea9fa commit 71e1ef7
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 63 deletions.
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, getObjectSubset, 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,
getObjectSubset(actual, expected),
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} more ${pluralize('property', stripped)} properties in actual)`
throw new AssertionError(message, { showDiff: true, expected, actual: actualSubset })
}
})
def('toMatch', function (expected: string | RegExp) {
const actual = this._obj as string
Expand Down
93 changes: 54 additions & 39 deletions packages/expect/src/jest-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,8 +537,12 @@ 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`
}

export function getObjectKeys(object: object): Array<string | symbol> {
Expand All @@ -550,48 +554,59 @@ export function getObjectKeys(object: object): Array<string | symbol> {
]
}

export function getObjectSubset(object: any, subset: any, customTesters: Array<Tester> = [], seenReferences: WeakMap<object, boolean> = new WeakMap()): 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) =>
getObjectSubset(object[i], sub, customTesters),
)
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
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).filter(key =>
hasPropertyInObject(subset, key),
)) {
trimmed[key] = seenReferences.has(object[key])
? seenReferences.get(object[key])
: getObjectSubset(
object[key],
subset[key],
customTesters,
seenReferences,
)
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
}

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

return { subset: getObjectSubsetWithContext()(object, subset), stripped }
}
88 changes: 72 additions & 16 deletions test/core/test/jest-expect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,10 +920,26 @@ function getError(f: () => unknown) {
it('toMatchObject error diff', () => {
setupColors(getDefaultColors())

// single property on root
// 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 more properties properties in 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 }",
"expected { a: 1, b: 2, c: { d: 4 } } to match object { b: 3 }
(3 more properties properties in actual)",
"- Expected
+ Received
Expand All @@ -934,10 +950,11 @@ it('toMatchObject error diff', () => {
]
`)

// nested property
expect(getError(() => expect({ a: 1, b: 2, c: { d: 4 } }).toMatchObject({ c: { d: 5 } }))).toMatchInlineSnapshot(`
// 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 } } to match object { c: { d: 5 } }",
"expected { a: 1, b: 2, c: { d: 4, e: 5 }, …(1) } to match object { c: { d: 5 } }
(5 more properties properties in actual)",
"- Expected
+ Received
Expand All @@ -950,10 +967,45 @@ it('toMatchObject error diff', () => {
]
`)

// multiple nested properties
// 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 more property properties in 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' } }",
"expected { a: 1, b: 2, c: { d: 4 }, …(2) } to match object { c: { d: 5 }, foo: { value: 'biz' } }
(4 more properties properties in actual)",
"- Expected
+ Received
Expand All @@ -970,20 +1022,24 @@ it('toMatchObject error diff', () => {
]
`)

// property on root, nothing stripped
expect(getError(() => expect({ a: 1, b: 2, c: { d: 4 } }).toMatchObject({ a: 1, b: 3, c: { d: 4 } }))).toMatchInlineSnapshot(`
// 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 { a: 1, b: 2, c: { d: 4 } } to match object { a: 1, b: 3, c: { d: 4 } }",
"expected { firstName: 'Vladimir', …(4) } to match object { family: 'House Atreides', …(1) }
(5 more properties properties in actual)",
"- Expected
+ Received
Object {
"a": 1,
- "b": 3,
+ "b": 2,
"c": Object {
"d": 4,
},
"children": Array [
Object {
- "firstName": "Paul",
+ "firstName": "Jessica",
},
],
- "family": "House Atreides",
+ "family": "House Harkonnen",
}",
]
`)
Expand Down

0 comments on commit 71e1ef7

Please sign in to comment.