Skip to content

Commit b52f40a

Browse files
committed
Added assert.matchObjectStrict + assert.matchObject
Co-authored-by: Antoine du Hamel <[email protected]>
1 parent 2dea6a4 commit b52f40a

File tree

3 files changed

+600
-0
lines changed

3 files changed

+600
-0
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: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,19 @@ const {
2929
Error,
3030
ErrorCaptureStackTrace,
3131
FunctionPrototypeBind,
32+
Map,
3233
NumberIsNaN,
34+
Object,
3335
ObjectAssign,
3436
ObjectIs,
3537
ObjectKeys,
3638
ObjectPrototypeIsPrototypeOf,
3739
ReflectApply,
40+
ReflectOwnKeys,
3841
RegExpPrototypeExec,
3942
RegExpPrototypeSymbolReplace,
4043
SafeMap,
44+
Set,
4145
String,
4246
StringPrototypeCharCodeAt,
4347
StringPrototypeIncludes,
@@ -608,6 +612,144 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
608612
}
609613
};
610614

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

0 commit comments

Comments
 (0)