Parameterized testing in Javascript
- 1/21/2013
- ·
- #index
Why describe in code what you can capture in a variable?
If I had my way I would have stopped programming years ago. Instead of pounding away at the keys, I would simply describe a list of inputs and outputs and tell a trusted computer to string them all together. Human error? Inconsistency? Gone overnight. Unfortunately, the start and the finish aren’t the real trick. The real trick is determining how to get between them.
Still, there’s enormous value in knowing what goes in and out. Mapping between the two can help determine whether a function is producing the expected output when valid inputs are available or handling errors appropriately when they aren’t. Even though internal issues may still go undetected, these basic checks are the low-hanging fruit of the unit testing world. They’re relatively easy to generate and test–so easy, in fact, that we often overlook the testing that strongly typed languages perform automatically.
When we have to write code, wrists and reviewers alike appreciate brevity. Even in an untyped language like Javascript, parameterized testing is not a new idea–making its relative underuse all that much more surprising.
So how can we stop writing tests? Consider a Jasmine test for limiting usernames to a goldilocks zone:
describe('checkUsername', function () {
it('fails for less than three characters', function () {
var result = checkUsername('rj');
expect(result).toEqual(false);
});
it('fails for more than six characters', function () {
var result = checkUsername('rj12345');
expect(result).toEqual(false);
});
it('passes when just right', function () {
var result = checkUsername('rj123');
expect(result).toEqual(true);
});
});
Fair enough, but this behavior doesn’t require nearly so many words. Consider the same test, this time written as a list of input and output parameters:
var runs = [
{ args: ['rj'], result: false },
{ args: ['rj12345'], result: false },
{ args: ['rj123'], result: true }
];
With support from a very simple runner, this array can perform the same tests we had before:
// an example, not a general case
describe('checkUsername', function () {
it('fails unless just right', function () {
runs.forEach(function (run) {
var result = checkUsername.apply(this, run.args);
expect(result).toEqual(run.result);
});
});
});
It doesn’t take much imagination to see the benefits of this approach whenever a new test needs to be added. There’s a downside, of course–unless test descriptions are included with each run parameter, test output suffers–but it can be easily remedied:
var runs = [
{ desc: 'too short', args: ['rj'], result: false },
{ desc: 'too long', args: ['rj12345'], result: false },
{ desc: 'just right', args: ['rj123'], result: true }
];
// an example, not a general case
describe('checkUsername', function () {
runs.forEach(function (run) {
var verb = run.result ? 'passes' : 'fails'
it(verb + ' when ' + run.desc, function () {
var result = checkUsername.apply(this, run.args);
expect(result).toEqual(run.result);
});
});
});
Despite the volumes of code I still end up writing by hand, there’s still satisfaction to be gained from writing no more than absolutely necessary.
Further Reading
If your tests can be parameterized (and odds are that they can), I encourage you to check out Ben Cherry’s general test runner. It’s a nice expansion on the examples above, and provides a slightly different perspective on test organization.