Skip to content

Commit

Permalink
assert: add matchObjectStrict() and matchObject()
Browse files Browse the repository at this point in the history
Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
  • Loading branch information
synapse committed Jun 17, 2024
1 parent 103623b commit 4164f10
Show file tree
Hide file tree
Showing 3 changed files with 664 additions and 1 deletion.
124 changes: 124 additions & 0 deletions doc/api/assert.md
Original file line number Diff line number Diff line change
Expand Up @@ -2539,6 +2539,130 @@ assert.throws(throwingFirst, /Second$/);
// AssertionError [ERR_ASSERTION]
```

## `assert.matchObject(actual, expected[, message])`

<!-- YAML
added: REPLACEME
-->

* `actual` {any}
* `expected` {any}
* `message` {string|Error}

Evaluates the equivalence between the `actual` and `expected` parameters by
performing a deep comparison. This function ensures that all properties defined
in the `expected` parameter exactly match those in the `actual` parameter in
both value and type, without allowing type coercion.

```mjs
import assert from 'node:assert';

assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 });
// OK

assert.matchObject({ a: 1, b: '2', c: 3 }, { a: 1, b: 2 });
// OK

assert.matchObject({ a: { b: { c: '1' } } }, { a: { b: { c: 1 } } });
// OK

assert.matchObject({ a: 1 }, { a: 1, b: 2 });
// AssertionError

assert.matchObject({ a: 1, b: true }, { a: 1, b: 'true' });
// AssertionError

assert.matchObject({ a: { b: 2 } }, { a: { b: 2, c: 3 } });
// AssertionError
```

```cjs
const assert = require('node:assert');

assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 });
// OK

assert.matchObject({ a: 1, b: '2', c: 3 }, { a: 1, b: 2 });
// OK

assert.matchObject({ a: { b: { c: '1' } } }, { a: { b: { c: 1 } } });
// OK

assert.matchObject({ a: 1 }, { a: 1, b: 2 });
// AssertionError: Expected key b

assert.matchObject({ a: 1, b: true }, { a: 1, b: 'true' });
// AssertionError

assert.matchObject({ a: { b: 2, d: 4 } }, { a: { b: 2, c: 3 } });
// AssertionError: Expected key c
```

If the values or keys are not equal in the `expected` parameter, an [`AssertionError`][] is thrown with a `message`
property set equal to the value of the `message` parameter. If the `message`
parameter is undefined, a default error message is assigned. If the `message`
parameter is an instance of an [`Error`][] then it will be thrown instead of the
`AssertionError`.

## `assert.matchObjectStrict(actual, expected[, message])`

<!-- YAML
added: REPLACEME
-->

* `actual` {any}
* `expected` {any}
* `message` {string|Error}

Assesses the equivalence between the `actual` and `expected` parameters through a
deep comparison, ensuring that all properties in the `expected` parameter are
present in the `actual` parameter with equivalent values, permitting type coercion
where necessary.

```mjs
import assert from 'node:assert';

assert.matchObject({ a: 1, b: 2 }, { a: 1, b: 2 });
// OK

assert.matchObject({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } });
// OK

assert.matchObject({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 });
// OK

assert.matchObject({ a: 1 }, { a: 1, b: 2 });
// AssertionError

assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 });
// AssertionError

assert.matchObject({ a: { b: 2 } }, { a: { b: '2' } });
// AssertionError
```

```cjs
const assert = require('node:assert');

assert.matchObject({ a: 1, b: 2 }, { a: 1, b: 2 });
// OK

assert.matchObject({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } });
// OK

assert.matchObject({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 });
// OK

assert.matchObject({ a: 1 }, { a: 1, b: 2 });
// AssertionError

assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 });
// AssertionError

assert.matchObject({ a: { b: 2 } }, { a: { b: '2' } });
// AssertionError
```

Due to the confusing error-prone notation, avoid a string as the second
argument.

Expand Down
146 changes: 145 additions & 1 deletion lib/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,25 @@ const {
Error,
ErrorCaptureStackTrace,
FunctionPrototypeBind,
FunctionPrototypeCall,
MapPrototypeGet,
MapPrototypeHas,
NumberIsNaN,
ObjectAssign,
ObjectGetPrototypeOf,
ObjectIs,
ObjectKeys,
ObjectPrototype,
ObjectPrototypeIsPrototypeOf,
ObjectPrototypeToString,
ReflectApply,
ReflectHas,
ReflectOwnKeys,
RegExpPrototypeExec,
RegExpPrototypeSymbolReplace,
SafeMap,
SafeSet,
SetPrototypeHas,
String,
StringPrototypeCharCodeAt,
StringPrototypeIncludes,
Expand All @@ -46,6 +56,7 @@ const {
StringPrototypeSlice,
StringPrototypeSplit,
StringPrototypeStartsWith,
SymbolIterator,
} = primordials;

const { Buffer } = require('buffer');
Expand All @@ -63,7 +74,7 @@ const {
const AssertionError = require('internal/assert/assertion_error');
const { openSync, closeSync, readSync } = require('fs');
const { inspect } = require('internal/util/inspect');
const { isPromise, isRegExp } = require('internal/util/types');
const { isPromise, isRegExp, isMap, isSet } = require('internal/util/types');
const { EOL } = require('internal/constants');
const { BuiltinModule } = require('internal/bootstrap/realm');
const { isError, deprecate } = require('internal/util');
Expand Down Expand Up @@ -608,6 +619,139 @@ assert.notStrictEqual = function notStrictEqual(actual, expected, message) {
}
};

/**
* Compares two objects or values recursively to check if they are equal.
* @param {any} actual - The actual value to compare.
* @param {any} expected - The expected value to compare.
* @param {boolean} [loose=false] - Whether to use loose comparison (==) or strict comparison (===). Defaults to false.
* @param {Set} [comparedObjects=new Set()] - Set to track compared objects for handling circular references.
* @returns {boolean} - Returns `true` if the actual value matches the expected value, otherwise `false`.
* @example
* // Loose comparison (default)
* compareBranch({a: 1, b: 2, c: 3}, {a: 1, b: '2'}); // true
*
* // Strict comparison
* compareBranch({a: 1, b: 2, c: 3}, {a: 1, b: 2}, true); // true
*/
function compareBranch(
actual,
expected,
loose = false,
comparedObjects = new SafeSet(),
) {
function isPlainObject(obj) {
if (typeof obj !== 'object' || obj === null) return false;
const proto = ObjectGetPrototypeOf(obj);
return proto === ObjectPrototype || proto === null || ObjectPrototypeToString(obj) === '[object Object]';
}

// Check for Map object equality
if (isMap(actual) && isMap(expected)) {
if (actual.size !== expected.size) return false;
const safeIterator = FunctionPrototypeCall(SafeMap.prototype[SymbolIterator], actual);
for (const { 0: key, 1: val } of safeIterator) {
if (!MapPrototypeHas(expected, key)) return false;
if (!compareBranch(val, MapPrototypeGet(expected, key), loose, comparedObjects))
return false;
}
return true;
}

// Check for Set object equality
if (isSet(actual) && isSet(expected)) {
if (actual.size !== expected.size) return false;
const safeIterator = FunctionPrototypeCall(SafeSet.prototype[SymbolIterator], actual);
for (const item of safeIterator) {
if (!SetPrototypeHas(expected, item)) return false;
}
return true;
}

// Check non object types equality
if (!isPlainObject(actual) || !isPlainObject(expected)) {
if (isDeepEqual === undefined) lazyLoadComparison();
return loose ? isDeepEqual(actual, expected) : isDeepStrictEqual(actual, expected);
}

// Check if actual and expected are null or not objects
if (actual == null || expected == null) {
return false;
}

// Use Reflect.ownKeys() instead of Object.keys() to include symbol properties
const keysExpected = ReflectOwnKeys(expected);

// Handle circular references
if (comparedObjects.has(actual)) {
return true;
}
comparedObjects.add(actual);

// Check if all expected keys and values match
for (let i = 0; i < keysExpected.length; i++) {
const key = keysExpected[i];
assert(
ReflectHas(actual, key),
new AssertionError({ message: `Expected key ${String(key)} not found in actual object` }),
);
if (!compareBranch(actual[key], expected[key], loose, comparedObjects)) {
return false;
}
}

return true;
}

/**
* The strict equivalence assertion test between two objects
* @param {any} actual
* @param {any} expected
* @param {string | Error} [message]
* @returns {void}
*/
assert.matchObjectStrict = function matchObjectStrict(
actual,
expected,
message,
) {
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
}

if (!compareBranch(actual, expected)) {
innerFail({
actual,
expected,
message,
operator: 'matchObjectStrict',
stackStartFn: matchObjectStrict,
});
}
};

/**
* The equivalence assertion test between two objects
* @param {any} actual
* @param {any} expected
* @param {string | Error} [message]
* @returns {void}
*/
assert.matchObject = function matchObject(actual, expected, message) {
if (arguments.length < 2) {
throw new ERR_MISSING_ARGS('actual', 'expected');
}

if (!compareBranch(actual, expected, true)) {
innerFail({
actual,
expected,
message,
operator: 'matchObject',
stackStartFn: matchObject,
});
}
};

class Comparison {
constructor(obj, keys, actual) {
for (const key of keys) {
Expand Down
Loading

0 comments on commit 4164f10

Please sign in to comment.