diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index 1df2260926e86..214abb01e878d 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -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' @@ -166,7 +166,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { 'expected #{this} to match object #{exp}', 'expected #{this} to not match object #{exp}', expected, - actual, + getObjectSubset(actual, expected), ) }) def('toMatch', function (expected: string | RegExp) { diff --git a/packages/expect/src/jest-utils.ts b/packages/expect/src/jest-utils.ts index 60322d0198150..7b2ce37d9d3d3 100644 --- a/packages/expect/src/jest-utils.ts +++ b/packages/expect/src/jest-utils.ts @@ -420,7 +420,7 @@ export function iterableEquality(a: any, b: any, customTesters: Array = /** * 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 @@ -540,3 +540,58 @@ 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 { + return [ + ...Object.keys(object), + ...Object.getOwnPropertySymbols(object).filter( + s => Object.getOwnPropertyDescriptor(object, s)?.enumerable, + ), + ] +} + +export function getObjectSubset(object: any, subset: any, customTesters: Array = [], seenReferences: WeakMap = 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), + ) + } + } + 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, + ) + } + + if (getObjectKeys(trimmed).length > 0) + return trimmed + } + return object +} diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index 990361d411465..a8ef055661a1f 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -901,24 +901,96 @@ 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 + 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 + + Received + + Object { + - "b": 3, + + "b": 2, + }", + ] + `) + + // nested property + expect(getError(() => expect({ a: 1, b: 2, c: { d: 4 } }).toMatchObject({ c: { d: 5 } }))).toMatchInlineSnapshot(` + [ + "expected { a: 1, b: 2, c: { d: 4 } } to match object { c: { d: 5 } }", + "- Expected + + Received + + Object { + "c": Object { + - "d": 5, + + "d": 4, + }, + }", + ] + `) + + // multiple nested properties + 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 + + Received + + Object { + "c": Object { + - "d": 5, + + "d": 4, + }, + "foo": Object { + - "value": "biz", + + "value": "bar", + }, + }", + ] + `) + + // property on root, nothing stripped + expect(getError(() => expect({ a: 1, b: 2, c: { d: 4 } }).toMatchObject({ a: 1, b: 3, c: { d: 4 } }))).toMatchInlineSnapshot(` + [ + "expected { a: 1, b: 2, c: { d: 4 } } to match object { a: 1, b: 3, c: { d: 4 } }", + "- Expected + + Received + + Object { + "a": 1, + - "b": 3, + + "b": 2, + "c": Object { + "d": 4, + }, + }", + ] + `) +}) + +it('toHaveProperty error diff', () => { + setupColors(getDefaultColors()) // non match value expect(getError(() => expect({ name: 'foo' }).toHaveProperty('name', 'bar'))).toMatchInlineSnapshot(`