I‘ve been doing a lot of work with JavaScript lately and so I‘ve been thinking a lot about how to effectively bring my experiences of BDD and TDD in other technologies to the party. There are a few test frameworks available, JsTestDriver by far the better looking of the lot. There is a specification framework, based on rspec as well. However, I couldn't find a JavaScript BDD story framework anywhere. So armed with some new knowledge from JavaScript: The Good Parts, I had a go at looking into what such a framework would look like. I was surprised as to how quickly I came up with a solution and how small the footprint of the end result is.
So here it is, in less than 200 lines, a JavaScript BDD Framework I've called it BehaviourJS and I've made it available under the GNU Lesser General Public License.
/**
* Standard means of augmenting everything with new methods
*
* @param name the name of the new method
* @param func the function we want invoked
*/
Object.prototype.method = function(name, func) {
if (!this.prototype[name]) {
this.prototype[name] = func;
return this;
}
};
// ------------------------------------------------------------ Should DSL
behaviourJs = {};
behaviourJs._shouldBe = function(expected) {
if(expected !== null) {
var exp = expected.valueOf();
var self = this.valueOf();
if(self === exp) {
return;
}
}
throw {
name: 'AssertionError',
message: 'Expected ' + expected + ' but was ' + this
};
};
Object.method('shouldBe', behaviourJs._shouldBe);
Object.method('shouldEqual', behaviourJs._shouldBe);
Object.method('shouldBeEqual', behaviourJs._shouldBe);
Object.method('shouldBeEqualTo', behaviourJs._shouldBe);
behaviourJs._shouldNotBe = function(expected) {
if(expected !== null) {
var exp = expected.valueOf();
var self = this.valueOf();
if (self !== exp) {
return;
}
throw {
name: 'AssertionError',
message: 'Expected not to be ' + expected + ' but was ' + this
};
}
};
Object.method('shouldNotBe', behaviourJs._shouldNotBe);
Object.method('shouldNotEqual', behaviourJs._shouldNotBe);
Object.method('shouldntBe', behaviourJs._shouldNotBe);
Object.method('shouldntEqual', behaviourJs._shouldNotBe);
// ------------------------------------------------------------ StoryInstance
behaviourJs.StoryInstance = function(block) {
block.call(this);
};
behaviourJs.StoryInstance.prototype.visit = function(visitor) {
visitor.visitStory(this);
};
behaviourJs.StoryInstance.prototype.run = function() {
this.visit(new behaviourJs.StoryRunner());
};
// ------------------------------------------------------------ ScenarioInstance
/**
* A runnable scenario.
*
* @param name the description of the scenario
* @param block the function containing the contents of the scenario (usually
* a series of Givens,When and Thens).
*/
behaviourJs.ScenarioInstance = function(name, block) {
this.name = name;
this._steps = [];
behaviourJs.ScenarioInstance.__currentScenario = this;
block.call();
behaviourJs.ScenarioInstance.__currentScenario = null;
behaviourJs.ScenarioInstance.__currentPhase = null;
};
behaviourJs.ScenarioInstance.__currentScenario = null;
behaviourJs.ScenarioInstance.__currentPhase = null;
behaviourJs.ScenarioInstance.prototype.addStep = function(step) {
this._steps[this._steps.length] = step;
};
behaviourJs.ScenarioInstance.prototype.visit = function(visitor) {
visitor.visitScenario(this);
};
// ------------------------------------------------------------ Step
behaviourJs.Step = function(type, meta, block) {
this._type = type;
this._meta = meta;
this._block = block;
};
behaviourJs.Step.prototype.visit = function(visitor) {
visitor.visitStep(this);
};
// ------------------------------------------------------------ StoryRunner
behaviourJs.StoryRunner = function() {
this.storyCtx = {};
};
behaviourJs.StoryRunner.prototype.visitStory = function(story) {
var title = 'Story: ' + story.description;
document.writeln(title);
var line = '';
for(var i = 0; i < title.length; ++i) {
line += '=';
}
document.writeln(line);
for (var scenIndex = 0; scenIndex < story.scenarios.length; ++scenIndex) {
story.scenarios[scenIndex].visit(this);
}
};
behaviourJs.StoryRunner.prototype.visitScenario = function(scenario) {
document.writeln('Scenario: ' + scenario.name);
for (var stepIndex = 0; stepIndex < scenario._steps.length; ++stepIndex) {
var step = scenario._steps[stepIndex];
step.visit(this);
}
document.write("\n");
};
behaviourJs.StoryRunner.prototype.visitStep = function(step) {
var stepDesc = step._type.toUpperCase() + ' ' + step._meta;
if (step._block === undefined || step._block === null) {
if (step._type === 'then') {
stepDesc += ' [PENDING]';
}
} else {
try {
step._block.call(this.storyCtx);
} catch(e) {
if (e.name == 'AssertionError') {
stepDesc += ' [FAIL : ' + e.message + ']';
} else {
stepDesc += ' [ERROR (' + e.name + ') : ' + e.message + ']';
}
}
}
document.writeln(stepDesc);
};
// ------------------------------------------------------------ DSL
Story = function(block) {
return new behaviourJs.StoryInstance(block);
};
Scenario = function(name, block) {
return new behaviourJs.ScenarioInstance(name, block);
};
Given = function(desc, block) {
var scenario = behaviourJs.ScenarioInstance.__currentScenario;
behaviourJs.ScenarioInstance.__currentPhase = 'given';
scenario.addStep(new behaviourJs.Step(behaviourJs.ScenarioInstance.__currentPhase, desc, block));
};
When = function(desc, block) {
var scenario = behaviourJs.ScenarioInstance.__currentScenario;
behaviourJs.ScenarioInstance.__currentPhase = 'when';
scenario.addStep(new behaviourJs.Step(behaviourJs.ScenarioInstance.__currentPhase, desc, block));
};
Then = function(desc, block) {
var scenario = behaviourJs.ScenarioInstance.__currentScenario;
behaviourJs.ScenarioInstance.__currentPhase = 'then';
scenario.addStep(new behaviourJs.Step(behaviourJs.ScenarioInstance.__currentPhase, desc, block));
};
And = function(desc, block) {
var scenario = behaviourJs.ScenarioInstance.__currentScenario;
scenario.addStep(new behaviourJs.Step(behaviourJs.ScenarioInstance.__currentPhase, desc, block));
};
My approach is heavily influenced by my experiences of using Easyb for BDD for Java. There are a few things I haven‘t added, such as before and after hooks.
Stacktrace support in JavaScript is sketchy and differs greatly across runtimes/browsers. One solution might be to integrate this attempt at universally obtaining a stacktrace. The should DSL is not as comprehensive as Easyb. Unlike Easyb the should DSL in BehaviourJS is completely extensible. This is possible because of the nature of the JavaScript langauge.
I mentioned runtimes/browsers earlier. The other potential issue is that I have only tested this in Firefox. It‘s the product of a couple of hours hacking so it‘ll be no surprise if it fails in other browsers.
Runners, build system and IDE support.
I realise that just implementing such a framework isn‘t even half of the effort required for a test framework to enable developers to practice TDD or BDD. For this framework to be useful to me or anyone else, it will need IDE support (IDEA and Eclipse at least) and build system support (Maven and maybe Ant).
The existing JavaScript test frameworks have had a lot of work put into this area and it shows. Especially with JsTestDriver‘s IDE support, which is awesome.
Maybe next weekend I‘ll have a look at plugin this into an existing test framework, such as JsTestDriver.
Example story
A story using the above framework might look something like this.
this.description = "This is an example story";
this.summary = {
as_a : "role",
i_want : "to perform some action",
so_that : "there is some perceived benefit"
};
this.scenarios = [
Scenario("Some scenario", function() {
Given("some string", function() {
this.someString = 'this';
});
And("some number", function() {
this.ten = 10;
});
When("something happens");
Then("some condition is evaluated", function() {
this.someString.shouldNotBe(null);
this.someString.shouldNotBe({});
this.someString.shouldNotBe(123);
this.someString.shouldNotBe('that');
this.someString.shouldBe(this.someString);
this.someString.shouldBe('this');
});
And("some other condition is evaluated", function () {
this.ten.shouldBe(10);
});
}),
Scenario("Checking incorect values", function() {
Given("some new precondition");
And("some other new precondition");
When("something new happens", function() {
this.animal = 'animal';
});
Then("some new condition is evaluated", function() {
try {
thisVar.doesNotExist();
} catch(e) {
// gulp
}
});
And("some other new condition is evaluated", function() {
try {
this.animal.shouldBe('farm');
} catch(e) {
e.name.shouldBe('AssertionError');
}
});
}),
Scenario('some complex objects', function() {
Given(' a complex object', function() {
this.MyThing = function(val) {
this._val = val;
};
this.someObj = new this.MyThing('a value');
});
And('a copy of it', function() {
this.sameObj = this.someObj;
});
And('a different object', function() {
this.differentObj = new this.MyThing('a value');
});
Then('the object and the copy should be the equal', function() {
this.someObj.shouldBe(this.sameObj);
this.sameObj.shouldBe(this.someObj);
});
And('the object and the different one should not', function() {
this.someObj.shouldNotBe(this.differentObj);
this.differentObj.shouldNotBe(this.someObj);
});
})
];
}).run();
The link to JavaScript: The Good Parts above is an affiliate link. It's a great book, written by a programmer for programmers.
First published on Jan 24, 2010. Last updated on: Jan 25, 2010.