From 99276399ab426b0f84dc1395f9e3530c97280aba Mon Sep 17 00:00:00 2001 From: Christophe Geers Date: Mon, 8 Apr 2024 14:20:02 +0200 Subject: [PATCH] feat: remove unrelated noise from diff for toMatchObject() (#5364) --- packages/expect/src/jest-expect.ts | 24 +++-- packages/expect/src/jest-utils.ts | 68 ++++++++++++- test/core/test/jest-expect.test.ts | 158 ++++++++++++++++++++++++++--- 3 files changed, 227 insertions(+), 23 deletions(-) diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index c20ade34e381..14d03771c12b 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' @@ -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 diff --git a/packages/expect/src/jest-utils.ts b/packages/expect/src/jest-utils.ts index 60322d019815..0d22002b0aac 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,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 { + return [ + ...Object.keys(object), + ...Object.getOwnPropertySymbols(object).filter( + s => Object.getOwnPropertyDescriptor(object, s)?.enumerable, + ), + ] +} + +export function getObjectSubset(object: any, subset: any, customTesters: Array = []): { subset: any; stripped: number } { + let stripped = 0 + + const getObjectSubsetWithContext = (seenReferences: WeakMap = 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 } +} diff --git a/test/core/test/jest-expect.test.ts b/test/core/test/jest-expect.test.ts index a637793803be..b1b613cb63fb 100644 --- a/test/core/test/jest-expect.test.ts +++ b/test/core/test/jest-expect.test.ts @@ -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(`