Skip to content

Commit

Permalink
feat: integrated chai-subset and added assert-based negation to con…
Browse files Browse the repository at this point in the history
…tainSubset (#1664)

Adds the features of `chai-subset` to core.
  • Loading branch information
BreadInvasion authored Jan 23, 2025
1 parent d044441 commit da2e109
Show file tree
Hide file tree
Showing 3 changed files with 359 additions and 1 deletion.
90 changes: 90 additions & 0 deletions lib/chai/core/assertions.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import {Assertion} from '../assertion.js';
import {AssertionError} from 'assertion-error';
import * as _ from '../utils/index.js';
import {config} from '../config.js';

const {flag} = _;

Expand Down Expand Up @@ -4061,3 +4062,92 @@ Assertion.addProperty('finite', function (_msg) {
'expected #{this} to not be a finite number'
);
});

/**
* A subset-aware compare function
*
* @param {unknown} expected
* @param {unknown} actual
* @returns {boolean}
*/
function compareSubset(expected, actual) {
if (expected === actual) {
return true;
}
if (typeof actual !== typeof expected) {
return false;
}
if (typeof expected !== 'object' || expected === null) {
return expected === actual;
}
if (!actual) {
return false;
}

if (Array.isArray(expected)) {
if (!Array.isArray(actual)) {
return false;
}
return expected.every(function (exp) {
return actual.some(function (act) {
return compareSubset(exp, act);
});
});
}

if (expected instanceof Date) {
if (actual instanceof Date) {
return expected.getTime() === actual.getTime();
} else {
return false;
}
}

return Object.keys(expected).every(function (key) {
var expectedValue = expected[key];
var actualValue = actual[key];
if (
typeof expectedValue === 'object' &&
expectedValue !== null &&
actualValue !== null
) {
return compareSubset(expectedValue, actualValue);
}
if (typeof expectedValue === 'function') {
return expectedValue(actualValue);
}
return actualValue === expectedValue;
});
}

/**
* ### .containSubset
*
* Asserts that the target primitive/object/array structure deeply contains all provided fields
* at the same key/depth as the provided structure.
*
* When comparing arrays, the target must contain the subset of at least one of each object/value in the subset array.
* Order does not matter.
*
* expect({name: {first: "John", last: "Smith"}}).to.containSubset({name: {first: "John"}});
*
* Add `.not` earlier in the chain to negate the assertion. This will cause the assertion to fail
* only if the target DOES contains the provided data at the expected keys/depths.
*
* @name containSubset
* @namespace BDD
* @public
*/
Assertion.addMethod('containSubset', function (expected) {
const actual = _.flag(this, 'object');
const showDiff = config.showDiff;

this.assert(
compareSubset(expected, actual),
'expected #{act} to contain subset #{exp}',
'expected #{act} to not contain subset #{exp}',
expected,
actual,
showDiff
);
});
45 changes: 44 additions & 1 deletion lib/chai/interface/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -3157,6 +3157,48 @@ assert.isNotEmpty = function (val, msg) {
new Assertion(val, msg, assert.isNotEmpty, true).to.not.be.empty;
};

/**
* ### .containsSubset(target, subset)
*
* Asserts that the target primitive/object/array structure deeply contains all provided fields
* at the same key/depth as the provided structure.
*
* When comparing arrays, the target must contain the subset of at least one of each object/value in the subset array.
* Order does not matter.
*
* assert.containsSubset(
* [{name: {first: "John", last: "Smith"}}, {name: {first: "Jane", last: "Doe"}}],
* [{name: {first: "Jane"}}]
* );
*
* @name containsSubset
* @alias containSubset
* @param {unknown} val
* @param {unknown} exp
* @param {string} msg _optional_
* @namespace Assert
* @public
*/
assert.containsSubset = function (val, exp, msg) {
new Assertion(val, msg).to.containSubset(exp);
};

/**
* ### .doesNotContainSubset(target, subset)
*
* The negation of assert.containsSubset.
*
* @name doesNotContainSubset
* @param {unknown} val
* @param {unknown} exp
* @param {string} msg _optional_
* @namespace Assert
* @public
*/
assert.doesNotContainSubset = function (val, exp, msg) {
new Assertion(val, msg).to.not.containSubset(exp);
};

/**
* Aliases.
*
Expand All @@ -3178,7 +3220,8 @@ const aliases = [
['isEmpty', 'empty'],
['isNotEmpty', 'notEmpty'],
['isCallable', 'isFunction'],
['isNotCallable', 'isNotFunction']
['isNotCallable', 'isNotFunction'],
['containsSubset', 'containSubset']
];
for (const [name, as] of aliases) {
assert[as] = assert[name];
Expand Down
225 changes: 225 additions & 0 deletions test/subset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import * as chai from '../index.js';

describe('containsSubset', function () {
const {assert, expect} = chai;
const should = chai.Should();

describe('plain object', function () {
var testedObject = {
a: 'b',
c: 'd'
};

it('should pass for smaller object', function () {
expect(testedObject).to.containSubset({
a: 'b'
});
});

it('should pass for same object', function () {
expect(testedObject).to.containSubset({
a: 'b',
c: 'd'
});
});

it('should pass for similar, but not the same object', function () {
expect(testedObject).to.not.containSubset({
a: 'notB',
c: 'd'
});
});
});

describe('complex object', function () {
var testedObject = {
a: 'b',
c: 'd',
e: {
foo: 'bar',
baz: {
qux: 'quux'
}
}
};

it('should pass for smaller object', function () {
expect(testedObject).to.containSubset({
a: 'b',
e: {
foo: 'bar'
}
});
});

it('should pass for smaller object', function () {
expect(testedObject).to.containSubset({
e: {
foo: 'bar',
baz: {
qux: 'quux'
}
}
});
});

it('should pass for same object', function () {
expect(testedObject).to.containSubset({
a: 'b',
c: 'd',
e: {
foo: 'bar',
baz: {
qux: 'quux'
}
}
});
});

it('should pass for similar, but not the same object', function () {
expect(testedObject).to.not.containSubset({
e: {
foo: 'bar',
baz: {
qux: 'notAQuux'
}
}
});
});

it('should fail if comparing when comparing objects to dates', function () {
expect(testedObject).to.not.containSubset({
e: new Date()
});
});
});

describe('circular objects', function () {
var object = {};

before(function () {
object.arr = [object, object];
object.arr.push(object.arr);
object.obj = object;
});

it('should contain subdocument', function () {
expect(object).to.containSubset({
arr: [{arr: []}, {arr: []}, [{arr: []}, {arr: []}]]
});
});

it('should not contain similar object', function () {
expect(object).to.not.containSubset({
arr: [{arr: ['just random field']}, {arr: []}, [{arr: []}, {arr: []}]]
});
});
});

describe('object with compare function', function () {
it('should pass when function returns true', function () {
expect({a: 5}).to.containSubset({a: (a) => a});
});

it('should fail when function returns false', function () {
expect({a: 5}).to.not.containSubset({a: (a) => !a});
});

it('should pass for function with no arguments', function () {
expect({a: 5}).to.containSubset({a: () => true});
});
});

describe('comparison of non objects', function () {
it('should fail if actual subset is null', function () {
expect(null).to.not.containSubset({a: 1});
});

it('should fail if expected subset is not a object', function () {
expect({a: 1}).to.not.containSubset(null);
});

it('should not fail for same non-object (string) variables', function () {
expect('string').to.containSubset('string');
});
});

describe('assert style of test', function () {
it('should find subset', function () {
assert.containsSubset({a: 1, b: 2}, {a: 1});
assert.containSubset({a: 1, b: 2}, {a: 1});
});

it('negated assert style should function', function () {
assert.doesNotContainSubset({a: 1, b: 2}, {a: 3});
});
});

describe('should style of test', function () {
const objectA = {a: 1, b: 2};

it('should find subset', function () {
objectA.should.containSubset({a: 1});
});

it('negated should style should function', function () {
objectA.should.not.containSubset({a: 3});
});
});

describe('comparison of dates', function () {
it('should pass for the same date', function () {
expect(new Date('2015-11-30')).to.containSubset(new Date('2015-11-30'));
});

it('should pass for the same date if nested', function () {
expect({a: new Date('2015-11-30')}).to.containSubset({
a: new Date('2015-11-30')
});
});

it('should fail for a different date', function () {
expect(new Date('2015-11-30')).to.not.containSubset(
new Date('2012-02-22')
);
});

it('should fail for a different date if nested', function () {
expect({a: new Date('2015-11-30')}).to.not.containSubset({
a: new Date('2012-02-22')
});
});

it('should fail for invalid expected date', function () {
expect(new Date('2015-11-30')).to.not.containSubset(
new Date('not valid date')
);
});

it('should fail for invalid actual date', function () {
expect(new Date('not valid actual date')).to.not.containSubset(
new Date('not valid expected date')
);
});
});

describe('cyclic objects', () => {
it('should pass', () => {
const child = {};
const parent = {
children: [child]
};
child.parent = parent;

const myObject = {
a: 1,
b: 'two',
c: parent
};
expect(myObject).to.containSubset({
a: 1,
c: parent
});
});
});
});

0 comments on commit da2e109

Please sign in to comment.