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

Integrated chai-subset and added assert-based negation to containSubset #1664

Merged
merged 3 commits into from
Jan 23, 2025
Merged
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
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
});
});
});
});
Loading