Declarative Specs for JavaScript
- 2/26/2014
- ·
- #index
The last thing a lazy software developer wants to do is write code. The shorter and simpler a program, the better. The same goes for its tests: it takes a certain volume of code to describe, set up, and run a test scenario, but keeping that volume as low as possible will help promote code that is clearer, safer, and easier to maintain.
To illustrate, let’s spec out a sign in form in as few words as possible. The behavior being tested isn’t important–the real goal is just to describe it in as few words as possible.
describe('when signing in', function () {
given.anUnauthenticatedUser();
var failingCases = [
{ desc: 'missing e-mail', body: { email: '' } },
{ desc: 'invalid e-mail', body: { email: 'not an email' } },
{ desc: 'missing password', body: { password: '' } },
{ desc: 'wrong password', body: { password: 'bogus' } }
// etc.
];
failingCases.forEach(function (spec) {
describe('and ' + spec.desc, function () {
given.aSigninRequest(spec.body);
expect.signinError();
});
});
describe('and e-mail is valid', function () {
given.aSigninRequest();
expect.redirectTo('/profile');
});
});
Succinct enough, and–apart from the generator used to convert a table of failing cases into a spec–utterly devoid of real code. This is important: even if we haven’t made time to learn Cucumber, we can still strive for a reasonable level of clarity in our own, project-specific DSL.
Let’s take a look at the setup. The implementation here uses JavaScript and borrows conventions from mocha, but similar techniques will apply across frameworks and languages.
Since our test is really about verifying an HTTP session, our first task is to find a way to make requests. I wrote supertest-session to hitch user sessions on the roof of T.J. Holowaychuck’s outstanding supertest and use it frequently when a test depends on persistent user sessions. It’s overkill when we’re just signing in, but it’s very helpful when a test must start with an authenticated user.
Creating an empty session looks about like you might expect:
given.anUnauthenticatedUser = function (attrs) {
reserve('session');
before(function () {
this.session = new Session();
});
};
If we wanted an authenticated user, this helper could be extended with registration, a sign-in, and any other steps required to reach the test scenario’s starting state. For here, though, we’ll just assign a new session and move on.
Next up is a helper for the specific scenario at hand–signing in a user:
given.aSigninRequest = function (attrs) {
var body = _.extend({
email: 'foobar@example.com',
password: 'foobar'
}, attrs);
demand('session');
reserve('response');
before(function (done) {
this.session.post('/signin')
.send(body)
.end(function (res) {
this.response = res;
done();
}.bind(this));
});
};
This helper consists of three parts. First, a factory extends a (valid) request body
with whatever attributes are passed in. This is mostly about creating invalid requests: if anything other than the default fixture is passed in, the factory should return an invalid output.
Next are calls to two utilities–demand
and reserve
. These are here to protect us from ourselves. It’s very convenient to recklessly attach data to whatever context happens by, but it’s hard to know what impact changes to this
will have downstream. To stay honest, demand
provides the barest assurances that a variable is attached to the current context while reserve
checks to make sure a name is available for use.
var _slice = Array.prototype.slice;
function demand () {
var args = _slice(arguments);
before(function () {
args.forEach(function (k) {
if (!this[k]) throw new ReferenceError(k + ' is not present');
}.bind(this));
});
}
function reserve () {
var args = _slice(arguments);
before(function () {
args.forEach(function (k) {
if (this[k]) throw new Error(k + ' is present');
}.bind(this));
});
after(function () {
args.forEach(function (k) {
delete this[k];
}.bind(this));
});
}
Notice that both will fail loudly by throwing errors if the suite’s variables get out of line? They could also be implemented as simple assertions, but since the error conditions Really Shouldn’t Happen a little noise won’t hurt us at all.
The final element in the given
conditions is the actual request. To sign the user in, we make a POST request to /signin
with the body in question. Once the request has been serviced, we store the response to the (reserved) response
property and call done
to notify mocha that setup is complete.
// from `given.aSigninRequest`
before(function (done) {
this.session.post('/signin')
.send(body)
.end(function (res) {
this.response = res;
done();
}.bind(this));
});
Next come the assertions. Here, we’ll inspect the response
set by the signin request to see how things went. It looks like you might expect:
expect.signinError = function () {
demand('response');
it('shows a sign-in error', function () {
expect(this.response.status).to.eq(400);
expect(this.response.body).to.match(/Please enter a valid e-mail and password/);
});
};
expect.redirectTo = function (path) {
demand('response');
it('redirects to ' + path, function () {
expect(this.response.status).to.eq(301);
expect(this.response.headers['location']).to.eq(path);
});
};
And there you have it. Even though we can’t remove the complexity of the test entirely, we can decompose it into a series of simple, recycleable functions. Shove them under the rug, and the result is a test suite that’s very nearly code-free.