We recently began to experiment with a new approach for writing expectations in automated tests. It’s inspired by the way a developer would investigate the behavior of a JavaScript app using the console or the node
REPL.
Currently, a typical Easel unit test—using Mocha and Chai—looks like this:
describe("EASEL.path#length", function () {
context("when the path has a few points", function () {
var path = EASEL.path([{ x: 0, y: 0 }, { x: 3, y: 4 }, { x: 4, y: 4 }, { x: 4, y: 1 }]);
it("returns the total length of its segments", function () {
expect(path.length()).to.equal(9);
});
});
});
The describe
, context
, and it
lines describe the test to humans and provide necessary structure for the test runner. The var path
line implements the context (“when the path has a few points”) by creating a path that has a few points, and the expect
line similarly implements the expectation (“it returns the total length of its segments”) that we’re trying to test.
If the length
method is working correctly, we’ll see something like this when we run the test:
✓ EASEL.path#length, when the path has a few points,
returns the total length of its segments
We can write the expectation a few different ways, depending on our choice of assertion library:
// Chai, expect.js (original):
expect(path.length()).to.equal(9);
// should.js:
path.length().should.equal(9);
// node's assert module:
assert.equal(path.length(), 9);
// better-assert:
assert(path.length() === 9);
// unexpected.js:
expect(path.length(), 'to equal', 9);
These libraries make different trade-offs between making expectations easy to read, easy to write, and able to produce helpful error messages. Read some of them aloud and you’ll say fluent English statements like “expect the path length to equal nine,” while others would best be spoken in a nasal monotone robot voice. Some libraries have APIs that you can learn in full in less than a minute, while others have pages of documentation enumerating the adverbs and prepositions required to make different kinds of expectations about different kinds of values. And if the length
method is broken and returns the wrong value, some libraries will tell you that the test failed because path.length()
wasn’t 9 (but not what value it got instead), while others will tell you that the test expected 9 and instead got 3 (but make you look elsewhere to figure out that it’s talking about the result of calling path.length()
).
We took a few days to build a proof of concept for an assertion library with a very small API and expectations that are as easy to read and write as possible, because they use exactly the syntax a Javascript developer already knows and uses every day.
With this library, the expectation can be written like this:
path.length() === 9
Or, more completely:
check(path, function(path) {
path.length() === 9;
});
Aside from one function—check
—there’s no API to learn. check
converts each expression statement in your function into an assertion of truthiness, so you get to write your expectations just like you would if you were checking for them in your program. You can copy an expectation straight from your program’s source code, or paste a line from your check
body into the dev tools console to evaluate it within the running app.1
The advantages of this approach are especially clear when you want to make more than one assertion on a value. With other expectation styles, increasing amounts of unidiomatic boilerplate make the important bits hard to pick out:
// Chai, expect.js:
expect(material.width).to.be.at.most(12);
expect(material.height).to.be.at.most(8);
expect(material.thickness).to.equal(0.5);
// should.js:
material.width.should.be.at.most(12);
material.height.should.be.at.most(8);
material.thickness.should.equal(0.5);
// node's assert module:
assert(material.width <= 12, 'invalid material width');
assert(material.height <= 8, 'invalid material height');
assert.equal(material.thickness, 0.5);
// better-assert:
assert(material.width <= 12);
assert(material.height <= 8);
assert(material.thickness === 0.5);
// unexpected.js:
expect(material.width, 'to be less than or equal to', 12);
expect(material.height, 'to be less than or equal to', 8);
expect(material.thickness, 'to equal', 0.5);
With check
, each expectation is simple and clear:
check(material, function(material) {
material.width <= 12;
material.height <= 8;
material.thickness === 0.5;
});
Because check
has access both to the values being tested and to the source code of the expectations, when an expectation fails, it can explain exactly why. In the example above, if material
is {"width": 12, "height": 10, "thickness": 0.5}
, check
will produce the following error message:
Expected `material.height <= 8`, but got `material.height`: 10
Equipped with information of both what went wrong and how, we’re likely to know exactly where to look to resolve the problem.
We’ll go deeper into the juicy technical details in our next post, and we’ll discuss some opportunities and challenges that we’d like to investigate further. In the meantime, check out check
on GitHub!
-
We see parallels in Julie Zhou’s concept of “invisible design”. For example:
Dropbox made syncing instantly understandable and seamless to use by adopting the same pattern that many people were already used to when dealing with their digital documents—that of native folders within the operating system. There were no new interfaces to learn, no new screens to get through. The familiarity of working within an existing, well-understood metaphor proved far easier to use than any new app could have been, no matter how simple or beautiful.
With
check
, Javascript expectations are plain, familiar Javascript.