Skip to content

Commit

Permalink
Added assert.matchObjectStrict + assert.matchObject
Browse files Browse the repository at this point in the history
Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

added tests for Set and Map from another context
  • Loading branch information
synapse committed Jun 13, 2024
1 parent 2dea6a4 commit c7e9950
Show file tree
Hide file tree
Showing 3 changed files with 623 additions and 1 deletion.
90 changes: 90 additions & 0 deletions doc/api/assert.md
Original file line number Diff line number Diff line change
Expand Up @@ -2539,6 +2539,96 @@ assert.throws(throwingFirst, /Second$/);
// AssertionError [ERR_ASSERTION]
```

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

<!-- YAML
added: REPLACEME
-->

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

Tests equivalence between the actual and expected parameters by performing
a deep comparison, ensuring that all properties and their values are equal,
allowing type coercion where necessary.

**Strict assertion mode**

An alias of [`assert.matchObjectStrict()`][].

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

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

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

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

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

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

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

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

If the values or keys are not equal, 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}

Tests strict equivalence between the actual and expected parameters by performing a
deep comparison, ensuring that all properties and their values are strictly equal,
without type coercion.

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

assert.matchObjectStrict({ a: 1 }, { a: 1 });
// OK

assert.matchObjectStrict({ b: 1 }, { b: '1' });
// AssertionError [ERR_ASSERTION]: Expected "actual" to be strictly unequal to: { b: '1' }

assert.notStrictEqual({ a: 1, b: 'string' }, { b: 'string', a: 1 });
// OK
```

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

assert.matchObjectStrict({ a: 1 }, { a: 1 });
// OK

assert.matchObjectStrict({ b: 1 }, { b: '1' });
// AssertionError [ERR_ASSERTION]: Expected "actual" to be strictly unequal to: { b: '1' }

assert.notStrictEqual({ a: 1, b: 'string' }, { b: 'string', a: 1 });
// OK
```

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

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

const { Buffer } = require('buffer');
Expand All @@ -63,7 +73,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 +618,144 @@ 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}, {a: 1, b: '2'}); // true
*
* // Strict comparison
* compareBranch({a: 1, b: 2}, {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 keysActual = ReflectOwnKeys(actual);
const keysExpected = ReflectOwnKeys(expected);

// Check if number of keys are equal
if (keysActual.length !== keysExpected.length) {
return false;
}

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

// Check if all keys and values recursively match
for (let i = 0; i < keysActual.length; i++) {
const key = keysActual[i];
if (
!keysExpected.includes(key) ||
!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 c7e9950

Please sign in to comment.