Skip to content

Commit

Permalink
Integrated chai-subset and added assert-based negation to containSu…
Browse files Browse the repository at this point in the history
…bset
  • Loading branch information
BreadInvasion committed Jan 17, 2025
1 parent 6d8d727 commit 021bd18
Show file tree
Hide file tree
Showing 3 changed files with 339 additions and 1 deletion.
87 changes: 87 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,89 @@ 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 {void}
*/
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 (!!expected && !actual) {
return false;
}

if (Array.isArray(expected)) {
if (typeof actual.length !== 'number') {
return false;
}
var aa = Array.prototype.slice.call(actual);
return expected.every(function (exp) {
return aa.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 eo = expected[key];
var ao = actual[key];
if (typeof eo === 'object' && eo !== null && ao !== null) {
return compareSubset(eo, ao);
}
if (typeof eo === 'function') {
return eo(ao);
}
return ao === eo;
});
}

/**
* ### .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
);
});
47 changes: 46 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 @@ -3176,4 +3218,7 @@ assert.isNotEmpty = function (val, msg) {
)('isFrozen', 'frozen')('isNotFrozen', 'notFrozen')('isEmpty', 'empty')(
'isNotEmpty',
'notEmpty'
)('isCallable', 'isFunction')('isNotCallable', 'isNotFunction');
)('isCallable', 'isFunction')('isNotCallable', 'isNotFunction')(
'containsSubset',
'containSubset'
);
206 changes: 206 additions & 0 deletions test/subset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import {assert, expect} from '../index.js';

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('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 021bd18

Please sign in to comment.