Testing Arrays and Objects with Chai.js

Titus Stone
Building Ibotta

--

When it comes to testing arrays and objects with Chai.js sometimes the selection of flagging properties and assertions becomes confusing. nested? deep? own? include? all? In this article we’ll dive into how you can be an ace at testing arrays and objects with Chai.js.

If you missed part 1, the differences between a flagging property and chainable method, it’s worth skimming through that article to catch up before proceeding.

Equality

At the heart of much of the confusion around making assertions on arrays and object is Javascript’s notion of equality.

expect([1, 2, 3]).to.equal([1, 2, 3]); // fails

This can be surprising, and downright frustrating at times, especially for programmers coming to Javascript from other languages. The equality being expressed in the example above is actually a core mechanic of Javascript, not of Chai.js. Open up a node session and try the following:

$ node
> [1,2,3] === [1,2,3]
false

Javascript equality is strict; it’s testing if the expression on the left is referring to the same point in memory that the expression on the right resides at. In the example above there are two copies of [1,2,3] (one on the left and one on the right). Because each copy has it’s own address in memory, Javascript’s strict equality considers them not equal as they do not share the same identity.

In Chai.js, the equal assertion, along with most other included Chai assertions, uses Javascipt’s strict equality.

During testing, it often is the case that strict equality is not desired, but instead equality in the sense that two objects are “equivalent” in that they have the same content.

Deep Equality

Chai.js solves this problem by providing a second equality assertion, eql. Eql is based on the deep-eql project. It works by looking at the content of the expressions being compared.

expect([1, 2, 3]).to.eql([1, 2, 3]); // passes

Sameness seems simple on the surface, but becomes more complex when those expressions have children which themselves have children. Deep equality compares sameness to all depths.

expect([{a:1}, {b:2}]).to.eql([{a:1}, {b:4}]); // fails
expect([{a:1}, {b:2}]).to.eql([{a:1}, {b:2}]); // passes

Similar to arrays, comparing two objects will use strict or deep equality.

expect({ a: 1 }).to.equal({ a: 1 }); // fails
expect({ a: 1 }).to.eql({ a: 1 }); // passes

Unordered Deep Equality

Deep equality is an excellent approach, but it enforces both order and contents when comparing arrays. Often the case arises in testing where the order doesn’t matter, but the contents do.

expect([1,2,3]).to.eql([3,2,1]); // fails

While eql compares content and enforces order, members only compares content allowing assertions that only care about values being present.

expect([1,2,3]).to.have.members([3,2,1]); // passes
expect([1,2,3]).to.have.members([1,2,3]); // passes

Danger Zone: A common mistake is to write the above assertion with include instead of have.

expect([1,2,3]).to.include.members([3,2,1]); // passes

The distinction between include and have is an important one. Have is a cosmetic property. It does nothing to the expectation but make it easier to read. However include is a chainable method. It is setting a flag which changes the behavior of members to only test for the given values being in the value under test, regardless of what other values are in the array.

expect([1,2,3,4]).to.include.members([3,2,1]); // passes
expect([1,2,3,4]).to.have.members([3,2,1]); // fails

Between ordering and the exactness of members, there’s a 2x2 truth table of which matcher to use in what instances.

  • Order Wholeness Matters .to.have.ordered.members
  • Unordered Wholeness Matters .to.have.members
  • Unordered Membership Matters .to.include.members
  • Ordered Membership Matters — Impossible case

Another point of confusion on testing unordered array membership is Chai’s two flagging properties any and all. As of version 4.x, those flags do not change the behavior of the members assertion (they only affect the keys assertion which is discussed below).

And and all can be included if it makes the expectation easier to read, but are effectively acting as cosmetic properties at that point and run the risk of just making it confusing for other engineers that are familiar with what any and all do on other assertions. The following two are functionally equivalent.

expect([1,2,3]).to.have.members([3,2,1]); // passes
expect([1,2,3]).to.have.all.members([3,2,1]); // passes

Similarly, with an array of primitive values (non-objects) it’s possible to write the same expectation with eql or .ordered.members.

expect([1,2,3]).to.eql([1,2,3]); // passes
expect([1,2,3]).to.have.ordered.members([1,2,3]); // passes

The difference between .ordered.members and .eql, aside from the way it reads to human readers, is the error message.

AssertionError: expected [ 1, 2, 3 ] to have the same ordered members as [ 3, 2, 1 ]

60 Fathoms Deep Equality

The difference between choosing eql and .ordered.members becomes more obvious when comparing arrays of objects. Mentioned before, eql is an equality assertion in Chai.js which will perform a deep equal instead of a strict equal. An third way to compare two arrays of primitive values is to use the flagging property deep.

expect([1, 2, 3]).to.deep.equal([1, 2, 3]); // passes

While the expectation above is functionally equivalent to using eql, the difference the deep flag makes is when mixed with other assertions, such as members.

expect([ {a:1} ]).to.have.deep.members([ {a:1} ]); // passes
expect([ {a:1} ]).to.have.members([ {a:1} ]); // fails

Important Concept: By default, all assertions in Chai are performing a strict equality comparison. Thus, asserting that an array of objects has a member object will cause those two objects to be compared strictly. Adding in the deep flag signals to the assertion to instead use deep equality for the comparison.

In case that was slightly mind blowing, it’s permissible at this point to react with, “Wow! That’s deep.”

Object Property Equality

When comparing objects, sometimes it’s only important what properties those objects have, not what the value of the properties are. A naïve approach would be to expect the existence of a property directly.

const obj = { a: 1, b: 2 };
expect(obj.c).to.not.be.undefined;

The main problem with writing tests this way is the error message it produces:

AssertionError: expected undefined not to be undefined

It doesn’t matter how long someone has been programming or how well they know a code base, error messages like this make problems unnecessarily difficult to figure out.

As expected, Chai provides the keys and property assertions which can assert the existence of a single property (property) or multiple properties (keys) on an object.

expect({ a: 1, b: 2 }).to.have.property('b'); // passes
expect({ a: 1, b: 2 }).to.have.keys([‘a’, ‘b’]); // passes

As an added bonus, if the value matters, the property assertion can be used with a second parameter, the expected value.

expect({ a: 1, b: 2 }).to.have.property(‘b’, 2); // passes

Deep Object Property Equality

An alternative way to test for the existence of a property is with the include assertion. Include is acting in the same capacity that it did with arrays, checking that the given properties are methods of the value under tests, not that the given properties are the whole of the value under test.

expect({ a: 1, b: 2 }).to.include({ b: 2 }); // passes

As with other examples in this article, the difference comes in two ways: first in the error message that is printed out when it fails; and second in how it interacts with the deep flag.

expect({ a: { c: 3 } }).to.include({ a: { c: 3 } }); // fails
expect({ a: { c: 3 } }).to.deep.include({ a: { c: 3 } }); // passes

deep.include is useful in some instances, but for anything more than a level or so deep can become unwieldy. For really deep inspections, the nested flagging property can be used. Nested signals that in all places where the property name (key) would have been, it is now a property path. This is particularly helpful when working with extremely complex JSON structures.

const obj = {
query: {
bool: {
filter: {
term: { id: '12345' }
}
}
}
};
// passes
expect(obj).to.have.nested.property('query.bool.filter.term.id');
// passes (works with deep as well)
expect(obj).to.have.deep.nested
.property('query.bool.filter.term', { id: '12345' });

Something interesting about nested is that arrays can also be referenced by path as well. If, in the example above, filter became an array of terms, and not just a single term, accessing the array by index could be included in the nested property name.

const obj = {
query: {
bool: {
filter: [{
term: { id: '12345' }
}]
}
}
};
// passes
expect(obj).to.have.nested.property('query.bool.filter[0].term.id');

The nested flagging property works with both the property and keys assertion.

It may seem slightly overwhelming at first, all of the things that are necessary to test arrays and objects, but after a few rounds of practice it will be second nature. Be sure to visit the Chai.js documentation to find more goodies awaiting you, or have a read through the code yourself to develop an even deeper understanding of how it works.

--

--