Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

assert: add matchObjectStrict and matchObject #53415

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Trott marked this conversation as resolved.
Show resolved Hide resolved

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

assert.matchObject({ a: 1, b: '2' }, { a: 1, b: 2 });
// OK
synapse marked this conversation as resolved.
Show resolved Hide resolved

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not convinced that this is sufficiently different from deepEqual and deepStrictEqual to warrant a new API. At the absolute least the documentation does not provide enough information for someone to be able to decide which to use

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jasnell If the expected object has fewer properties than the actual object, deepEqual will throw an error based on your example. On the other hand, matchObject will compare the existing properties and values in the expected object, allowing for a partial comparison.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I get that, I just do not think there's enough justification for a new api. A new option to the existing method could do the same.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was some conversation in the original issue about whether it should be:

  1. a new method, e.g. matchObject
  2. an option to the existing method, e.g. deepStrictEqual(..., { partial: tru })
  3. a utility function for the existing method, e.g. deepStrictEqual(..., objectContaining(...))

here @synapse decided to go with option 1, lacking a clear consensus on which option made the most sense


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