Skip to content

Commit 3985aa5

Browse files
authored
fix(expect): scope toMatchObject diff to only keys present in expected (#7078)
Fixes #7077
1 parent 1cd63ca commit 3985aa5

3 files changed

Lines changed: 124 additions & 1 deletion

File tree

expect/_matchers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { getMockCalls } from "./_mock_util.ts";
2121
import { inspectArg, inspectArgs } from "./_inspect_args.ts";
2222
import {
2323
buildEqualOptions,
24+
getObjectSubset,
2425
iterableEquality,
2526
subsetEquality,
2627
} from "./_utils.ts";
@@ -601,7 +602,8 @@ export function toMatchObject(
601602
: defaultMessage,
602603
);
603604
} else {
604-
const defaultMessage = buildEqualErrorMessage(received, expected);
605+
const subset = getObjectSubset(received, expected, context.customTesters);
606+
const defaultMessage = buildEqualErrorMessage(subset, expected);
605607
throw new AssertionError(
606608
context.customMessage
607609
? `${context.customMessage}: ${defaultMessage}`

expect/_to_match_object_test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,4 +227,75 @@ Deno.test("expect().toMatchObject() displays a diff", async (t) => {
227227
);
228228
},
229229
);
230+
231+
await t.step("omits keys not in expected", () => {
232+
const e = assertThrows(
233+
() =>
234+
expect({ a: 1, b: 2, c: 3, d: { e: 4, f: 5 } }).toMatchObject({
235+
a: 1,
236+
d: { e: 999 },
237+
}),
238+
AssertionError,
239+
);
240+
// The diff should mention the mismatched value
241+
assertMatch(e.message, /999/);
242+
// The diff should NOT mention keys absent from expected
243+
assertNotMatch(e.message, /b:/);
244+
assertNotMatch(e.message, /c:/);
245+
assertNotMatch(e.message, /f:/);
246+
});
247+
248+
await t.step("omits keys not in expected with arrays", () => {
249+
const e = assertThrows(
250+
() =>
251+
expect([{ a: 1, extra: true }, { b: 2 }]).toMatchObject([
252+
{ a: 999 },
253+
{ b: 2 },
254+
]),
255+
AssertionError,
256+
);
257+
assertMatch(e.message, /999/);
258+
assertNotMatch(e.message, /extra/);
259+
});
260+
261+
await t.step("omits keys not in expected with Date values", () => {
262+
const e = assertThrows(
263+
() =>
264+
expect({
265+
d: new Date("2020-01-01"),
266+
extra: "noise",
267+
}).toMatchObject({ d: new Date("2025-01-01") }),
268+
AssertionError,
269+
);
270+
assertNotMatch(e.message, /noise/);
271+
});
272+
273+
await t.step("omits keys not in expected with equal nested subset", () => {
274+
const e = assertThrows(
275+
() =>
276+
expect({
277+
a: { x: 1, y: 2 },
278+
b: 999,
279+
extra: true,
280+
}).toMatchObject({ a: { x: 1 }, b: 42 }),
281+
AssertionError,
282+
);
283+
// b is the mismatch
284+
assertMatch(e.message, /999/);
285+
assertMatch(e.message, /42/);
286+
// extra top-level key should be omitted
287+
assertNotMatch(e.message, /extra/);
288+
// y should be omitted (not in expected.a)
289+
assertNotMatch(e.message, /y:/);
290+
});
291+
292+
await t.step("handles circular references without throwing", () => {
293+
const received: Record<string, unknown> = { a: 1 };
294+
received.self = received;
295+
const e = assertThrows(
296+
() => expect(received).toMatchObject({ a: 999, self: {} }),
297+
AssertionError,
298+
);
299+
assertMatch(e.message, /999/);
300+
});
230301
});

expect/_utils.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,53 @@ export function subsetEquality(
281281

282282
return subsetEqualityWithContext()(object, subset);
283283
}
284+
285+
// Ported from https://github.com/jestjs/jest/blob/442c7f692e3a92f14a2fb56c1737b26fc663a0ef/packages/expect-utils/src/utils.ts#L82
286+
export function getObjectSubset(
287+
object: unknown,
288+
subset: unknown,
289+
customTesters: Tester[] = [],
290+
seenReferences: WeakMap<object, boolean> = new WeakMap(),
291+
): unknown {
292+
if (Array.isArray(object)) {
293+
if (Array.isArray(subset) && subset.length === object.length) {
294+
return subset.map((_: unknown, i: number) =>
295+
getObjectSubset(object[i], subset[i], customTesters, seenReferences)
296+
);
297+
}
298+
} else if (object instanceof Date) {
299+
return object;
300+
} else if (isObject(object) && isObject(subset)) {
301+
if (
302+
equal(object, subset, {
303+
customTesters: [...customTesters, iterableEquality, subsetEquality],
304+
})
305+
) {
306+
return subset;
307+
}
308+
309+
const obj = object as Record<string, unknown>;
310+
const sub = subset as Record<string, unknown>;
311+
const trimmed: Record<string, unknown> = {};
312+
seenReferences.set(object as object, true);
313+
314+
for (const key of Object.keys(obj)) {
315+
if (!Object.prototype.hasOwnProperty.call(sub, key)) continue;
316+
317+
const val = obj[key];
318+
if (typeof val === "object" && val !== null && seenReferences.has(val)) {
319+
trimmed[key] = val;
320+
} else {
321+
trimmed[key] = getObjectSubset(
322+
val,
323+
sub[key],
324+
customTesters,
325+
seenReferences,
326+
);
327+
}
328+
}
329+
330+
if (Object.keys(trimmed).length > 0) return trimmed;
331+
}
332+
return object;
333+
}

0 commit comments

Comments
 (0)