Skip to content

Commit 00744fe

Browse files
committed
assert: add matchObjectStrict() and matchObject()
Co-authored-by: Antoine du Hamel <[email protected]>
1 parent 103623b commit 00744fe

File tree

3 files changed

+665
-1
lines changed

3 files changed

+665
-1
lines changed

doc/api/assert.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2539,6 +2539,130 @@ assert.throws(throwingFirst, /Second$/);
25392539
// AssertionError [ERR_ASSERTION]
25402540
```
25412541

2542+
## `assert.matchObject(actual, expected[, message])`
2543+
2544+
<!-- YAML
2545+
added: REPLACEME
2546+
-->
2547+
2548+
* `actual` {any}
2549+
* `expected` {any}
2550+
* `message` {string|Error}
2551+
2552+
Evaluates the equivalence between the `actual` and `expected` parameters by
2553+
performing a deep comparison. This function ensures that all properties defined
2554+
in the `expected` parameter exactly match those in the `actual` parameter in
2555+
both value and type, without allowing type coercion.
2556+
2557+
```mjs
2558+
import assert from 'node:assert';
2559+
2560+
assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 });
2561+
// OK
2562+
2563+
assert.matchObject({ a: 1, b: '2', c: 3 }, { a: 1, b: 2 });
2564+
// OK
2565+
2566+
assert.matchObject({ a: { b: { c: '1' } } }, { a: { b: { c: 1 } } });
2567+
// OK
2568+
2569+
assert.matchObject({ a: 1 }, { a: 1, b: 2 });
2570+
// AssertionError
2571+
2572+
assert.matchObject({ a: 1, b: true }, { a: 1, b: 'true' });
2573+
// AssertionError
2574+
2575+
assert.matchObject({ a: { b: 2 } }, { a: { b: 2, c: 3 } });
2576+
// AssertionError
2577+
```
2578+
2579+
```cjs
2580+
const assert = require('node:assert');
2581+
2582+
assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 });
2583+
// OK
2584+
2585+
assert.matchObject({ a: 1, b: '2', c: 3 }, { a: 1, b: 2 });
2586+
// OK
2587+
2588+
assert.matchObject({ a: { b: { c: '1' } } }, { a: { b: { c: 1 } } });
2589+
// OK
2590+
2591+
assert.matchObject({ a: 1 }, { a: 1, b: 2 });
2592+
// AssertionError: Expected key b
2593+
2594+
assert.matchObject({ a: 1, b: true }, { a: 1, b: 'true' });
2595+
// AssertionError
2596+
2597+
assert.matchObject({ a: { b: 2, d: 4 } }, { a: { b: 2, c: 3 } });
2598+
// AssertionError: Expected key c
2599+
```
2600+
2601+
If the values or keys are not equal in the `expected` parameter, an [`AssertionError`][] is thrown with a `message`
2602+
property set equal to the value of the `message` parameter. If the `message`
2603+
parameter is undefined, a default error message is assigned. If the `message`
2604+
parameter is an instance of an [`Error`][] then it will be thrown instead of the
2605+
`AssertionError`.
2606+
2607+
## `assert.matchObjectStrict(actual, expected[, message])`
2608+
2609+
<!-- YAML
2610+
added: REPLACEME
2611+
-->
2612+
2613+
* `actual` {any}
2614+
* `expected` {any}
2615+
* `message` {string|Error}
2616+
2617+
Assesses the equivalence between the `actual` and `expected` parameters through a
2618+
deep comparison, ensuring that all properties in the `expected` parameter are
2619+
present in the `actual` parameter with equivalent values, permitting type coercion
2620+
where necessary.
2621+
2622+
```mjs
2623+
import assert from 'node:assert';
2624+
2625+
assert.matchObject({ a: 1, b: 2 }, { a: 1, b: 2 });
2626+
// OK
2627+
2628+
assert.matchObject({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } });
2629+
// OK
2630+
2631+
assert.matchObject({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 });
2632+
// OK
2633+
2634+
assert.matchObject({ a: 1 }, { a: 1, b: 2 });
2635+
// AssertionError
2636+
2637+
assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 });
2638+
// AssertionError
2639+
2640+
assert.matchObject({ a: { b: 2 } }, { a: { b: '2' } });
2641+
// AssertionError
2642+
```
2643+
2644+
```cjs
2645+
const assert = require('node:assert');
2646+
2647+
assert.matchObject({ a: 1, b: 2 }, { a: 1, b: 2 });
2648+
// OK
2649+
2650+
assert.matchObject({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } });
2651+
// OK
2652+
2653+
assert.matchObject({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 });
2654+
// OK
2655+
2656+
assert.matchObject({ a: 1 }, { a: 1, b: 2 });
2657+
// AssertionError
2658+
2659+
assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 });
2660+
// AssertionError
2661+
2662+
assert.matchObject({ a: { b: 2 } }, { a: { b: '2' } });
2663+
// AssertionError
2664+
```
2665+
25422666
Due to the confusing error-prone notation, avoid a string as the second
25432667
argument.
25442668

lib/assert.js

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,25 @@ const {
2929
Error,
3030
ErrorCaptureStackTrace,
3131
FunctionPrototypeBind,
32+
FunctionPrototypeCall,
33+
MapPrototypeGet,
34+
MapPrototypeHas,
3235
NumberIsNaN,
3336
ObjectAssign,
37+
ObjectGetPrototypeOf,
3438
ObjectIs,
3539
ObjectKeys,
40+
ObjectPrototype,
3641
ObjectPrototypeIsPrototypeOf,
42+
ObjectPrototypeToString,
3743
ReflectApply,
44+
ReflectHas,
45+
ReflectOwnKeys,
3846
RegExpPrototypeExec,
3947
RegExpPrototypeSymbolReplace,
4048
SafeMap,
49+
SafeSet,
50+
SetPrototypeHas,
4151
String,
4252
StringPrototypeCharCodeAt,
4353
StringPrototypeIncludes,
@@ -46,6 +56,7 @@ const {
4656
StringPrototypeSlice,
4757
StringPrototypeSplit,
4858
StringPrototypeStartsWith,
59+
SymbolIterator,
4960
} = primordials;
5061

5162
const { Buffer } = require('buffer');
@@ -63,7 +74,7 @@ const {
6374
const AssertionError = require('internal/assert/assertion_error');
6475
const { openSync, closeSync, readSync } = require('fs');
6576
const { inspect } = require('internal/util/inspect');
66-
const { isPromise, isRegExp } = require('internal/util/types');
77+
const { isPromise, isRegExp, isMap, isSet } = require('internal/util/types');
6778
const { EOL } = require('internal/constants');
6879
const { BuiltinModule } = require('internal/bootstrap/realm');
6980
const { isError, deprecate } = require('internal/util');
@@ -608,6 +619,139 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
608619
}
609620
};
610621

622+
/**
623+
* Compares two objects or values recursively to check if they are equal.
624+
* @param {any} actual - The actual value to compare.
625+
* @param {any} expected - The expected value to compare.
626+
* @param {boolean} [loose=false] - Whether to use loose comparison (==) or strict comparison (===). Defaults to false.
627+
* @param {Set} [comparedObjects=new Set()] - Set to track compared objects for handling circular references.
628+
* @returns {boolean} - Returns `true` if the actual value matches the expected value, otherwise `false`.
629+
* @example
630+
* // Loose comparison (default)
631+
* compareBranch({a: 1, b: 2, c: 3}, {a: 1, b: '2'}); // true
632+
*
633+
* // Strict comparison
634+
* compareBranch({a: 1, b: 2, c: 3}, {a: 1, b: 2}, true); // true
635+
*/
636+
function compareBranch(
637+
actual,
638+
expected,
639+
loose = false,
640+
comparedObjects = new SafeSet(),
641+
) {
642+
function isPlainObject(obj) {
643+
if (typeof obj !== 'object' || obj === null) return false;
644+
const proto = ObjectGetPrototypeOf(obj);
645+
return proto === ObjectPrototype || proto === null || ObjectPrototypeToString(obj) === '[object Object]';
646+
}
647+
648+
// Check for Map object equality
649+
if (isMap(actual) && isMap(expected)) {
650+
if (actual.size !== expected.size) return false;
651+
const safeIterator = FunctionPrototypeCall(SafeMap.prototype[SymbolIterator], actual);
652+
for (const { 0: key, 1: val } of safeIterator) {
653+
if (!MapPrototypeHas(expected, key)) return false;
654+
if (!compareBranch(val, MapPrototypeGet(expected, key), loose, comparedObjects))
655+
return false;
656+
}
657+
return true;
658+
}
659+
660+
// Check for Set object equality
661+
if (isSet(actual) && isSet(expected)) {
662+
if (actual.size !== expected.size) return false;
663+
const safeIterator = FunctionPrototypeCall(SafeSet.prototype[SymbolIterator], actual);
664+
for (const item of safeIterator) {
665+
if (!SetPrototypeHas(expected, item)) return false;
666+
}
667+
return true;
668+
}
669+
670+
// Check non object types equality
671+
if (!isPlainObject(actual) || !isPlainObject(expected)) {
672+
if (isDeepEqual === undefined) lazyLoadComparison();
673+
return loose ? isDeepEqual(actual, expected) : isDeepStrictEqual(actual, expected);
674+
}
675+
676+
// Check if actual and expected are null or not objects
677+
if (actual == null || expected == null) {
678+
return false;
679+
}
680+
681+
// Use Reflect.ownKeys() instead of Object.keys() to include symbol properties
682+
const keysExpected = ReflectOwnKeys(expected);
683+
684+
// Handle circular references
685+
if (comparedObjects.has(actual)) {
686+
return true;
687+
}
688+
comparedObjects.add(actual);
689+
690+
// Check if all expected keys and values match
691+
for (let i = 0; i < keysExpected.length; i++) {
692+
const key = keysExpected[i];
693+
assert(
694+
ReflectHas(actual, key),
695+
new AssertionError({ message: `Expected key ${String(key)} not found in actual object` }),
696+
);
697+
if (!compareBranch(actual[key], expected[key], loose, comparedObjects)) {
698+
return false;
699+
}
700+
}
701+
702+
return true;
703+
}
704+
705+
/**
706+
* The strict equivalence assertion test between two objects
707+
* @param {any} actual
708+
* @param {any} expected
709+
* @param {string | Error} [message]
710+
* @returns {void}
711+
*/
712+
assert.matchObjectStrict = function matchObjectStrict(
713+
actual,
714+
expected,
715+
message,
716+
) {
717+
if (arguments.length < 2) {
718+
throw new ERR_MISSING_ARGS('actual', 'expected');
719+
}
720+
721+
if (!compareBranch(actual, expected)) {
722+
innerFail({
723+
actual,
724+
expected,
725+
message,
726+
operator: 'matchObjectStrict',
727+
stackStartFn: matchObjectStrict,
728+
});
729+
}
730+
};
731+
732+
/**
733+
* The equivalence assertion test between two objects
734+
* @param {any} actual
735+
* @param {any} expected
736+
* @param {string | Error} [message]
737+
* @returns {void}
738+
*/
739+
assert.matchObject = function matchObject(actual, expected, message) {
740+
if (arguments.length < 2) {
741+
throw new ERR_MISSING_ARGS('actual', 'expected');
742+
}
743+
744+
if (!compareBranch(actual, expected, true)) {
745+
innerFail({
746+
actual,
747+
expected,
748+
message,
749+
operator: 'matchObject',
750+
stackStartFn: matchObject,
751+
});
752+
}
753+
};
754+
611755
class Comparison {
612756
constructor(obj, keys, actual) {
613757
for (const key of keys) {

0 commit comments

Comments
 (0)