Skip to content

Commit c7e9950

Browse files
committed
Added assert.matchObjectStrict + assert.matchObject
Co-authored-by: Antoine du Hamel <[email protected]> added tests for Set and Map from another context
1 parent 2dea6a4 commit c7e9950

File tree

3 files changed

+623
-1
lines changed

3 files changed

+623
-1
lines changed

doc/api/assert.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2539,6 +2539,96 @@ 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+
Tests equivalence between the actual and expected parameters by performing
2553+
a deep comparison, ensuring that all properties and their values are equal,
2554+
allowing type coercion where necessary.
2555+
2556+
**Strict assertion mode**
2557+
2558+
An alias of [`assert.matchObjectStrict()`][].
2559+
2560+
```mjs
2561+
import assert from 'node:assert';
2562+
2563+
assert.matchObject({ a: 1 }, { a: 1 });
2564+
// OK
2565+
2566+
assert.matchObject({ b: 1 }, { b: 2 });
2567+
// AssertionError: 1 != 2
2568+
2569+
assert.matchObject({ c: 1 }, { c: '1' });
2570+
// OK
2571+
```
2572+
2573+
```cjs
2574+
const assert = require('node:assert');
2575+
2576+
assert.matchObject({ a: 1 }, { a: 1 });
2577+
// OK
2578+
2579+
assert.matchObject({ b: 1 }, { b: 2 });
2580+
// AssertionError: 1 != 2
2581+
2582+
assert.matchObject({ c: 1 }, { c: '1' });
2583+
// OK
2584+
```
2585+
2586+
If the values or keys are not equal, an [`AssertionError`][] is thrown with a `message`
2587+
property set equal to the value of the `message` parameter. If the `message`
2588+
parameter is undefined, a default error message is assigned. If the `message`
2589+
parameter is an instance of an [`Error`][] then it will be thrown instead of the
2590+
`AssertionError`.
2591+
2592+
## `assert.matchObjectStrict(actual, expected[, message])`
2593+
2594+
<!-- YAML
2595+
added: REPLACEME
2596+
-->
2597+
2598+
* `actual` {any}
2599+
* `expected` {any}
2600+
* `message` {string|Error}
2601+
2602+
Tests strict equivalence between the actual and expected parameters by performing a
2603+
deep comparison, ensuring that all properties and their values are strictly equal,
2604+
without type coercion.
2605+
2606+
```mjs
2607+
import assert from 'node:assert/strict';
2608+
2609+
assert.matchObjectStrict({ a: 1 }, { a: 1 });
2610+
// OK
2611+
2612+
assert.matchObjectStrict({ b: 1 }, { b: '1' });
2613+
// AssertionError [ERR_ASSERTION]: Expected "actual" to be strictly unequal to: { b: '1' }
2614+
2615+
assert.notStrictEqual({ a: 1, b: 'string' }, { b: 'string', a: 1 });
2616+
// OK
2617+
```
2618+
2619+
```cjs
2620+
const assert = require('node:assert/strict');
2621+
2622+
assert.matchObjectStrict({ a: 1 }, { a: 1 });
2623+
// OK
2624+
2625+
assert.matchObjectStrict({ b: 1 }, { b: '1' });
2626+
// AssertionError [ERR_ASSERTION]: Expected "actual" to be strictly unequal to: { b: '1' }
2627+
2628+
assert.notStrictEqual({ a: 1, b: 'string' }, { b: 'string', a: 1 });
2629+
// OK
2630+
```
2631+
25422632
Due to the confusing error-prone notation, avoid a string as the second
25432633
argument.
25442634

lib/assert.js

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,24 @@ 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+
ReflectOwnKeys,
3845
RegExpPrototypeExec,
3946
RegExpPrototypeSymbolReplace,
4047
SafeMap,
48+
SafeSet,
49+
SetPrototypeHas,
4150
String,
4251
StringPrototypeCharCodeAt,
4352
StringPrototypeIncludes,
@@ -46,6 +55,7 @@ const {
4655
StringPrototypeSlice,
4756
StringPrototypeSplit,
4857
StringPrototypeStartsWith,
58+
SymbolIterator,
4959
} = primordials;
5060

5161
const { Buffer } = require('buffer');
@@ -63,7 +73,7 @@ const {
6373
const AssertionError = require('internal/assert/assertion_error');
6474
const { openSync, closeSync, readSync } = require('fs');
6575
const { inspect } = require('internal/util/inspect');
66-
const { isPromise, isRegExp } = require('internal/util/types');
76+
const { isPromise, isRegExp, isMap, isSet } = require('internal/util/types');
6777
const { EOL } = require('internal/constants');
6878
const { BuiltinModule } = require('internal/bootstrap/realm');
6979
const { isError, deprecate } = require('internal/util');
@@ -608,6 +618,144 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
608618
}
609619
};
610620

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

0 commit comments

Comments
 (0)