On Github eamodeorubio / bdd-with-js
By Enrique Amodeo / @eamodeorubio
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 2.0 Generic LicenseFeature: get cash from an ATM Background: Given the ATM has 1000 And the user John is authenticated And the user's account has 5000 Scenario: success When the user asks the ATM for 500 Then the ATM will have 500 And the user's account will have 4500 And the ATM will provide 500 in cash Scenario: not enough money in the ATM When the user asks the ATM for 1500 Then the ATM will have 1000 And the user's account will have 5000 And the ATM will notify the user it does not have enough cash
Will cucumber test the features?
No, not really
Parse the gherkin Match gherkin with your test functions Execute the matched test functions Generate a reportmodule.exports = function() { this.World = require('./support/World'); this.Given("the ATM has $cash", function(cash,done){ this.ATM().cashAvailable(Number(cash), done); }); this.When("the user asks the ATM for $cash", function(cash,done){ this.ATM().requestCash(Number(cash), done); }); this.Then("the user's account will have $cash", function(cash, done) { this.DB().accountFor(this.userId).then(function(acc) { expect(cash).to.be.eql(acc.cash); }).then(done, done); }); };
// A new World will be created by scenario module.exports = function(done) { var db, atm; this.DB = function() { return db; }; this.ATM = function() { return atm; }; openDB(function(DB) { db = DB; openUI(function(ui) { atm = new ATM(db, ui); done(); }); }); };
// Before & after each scenario module.exports = function() { this.Before(function(done) { this.DB() .eraseAll() .createDefaultData() .then(done, done); }); this.After(function(done) { var db = this.DB(); this.ATM() .shutdown() .fin(db.shutdown.bind(db)) .fin(done); }); };
// package.json { // .... "scripts": { "cucumber": "cucumber-js features/ -r features/step_defs/" }, "devDependencies": { "cucumber": "~0.3.0" } }
$> NODE_ENV=test npm run-script cucumber
Just write your functional tests!
describe('Feature: get cash from an ATM:', function() { context('Scenario: success', function() { describe('When the user asks the ATM for 500', function() { it('Then the ATM will have 500', function() { expect(world().getATM().remainingCash()).to.be.eql(500); }); it("Then the user's account will have 4500", function(done) { world().getDB().accountFor(userId).then(function(acc) { expect(cash).to.be.eql(acc.cash); }).then(done, done); }); }); }); });
describe and context only for reports?
var world = require('./support/world'); describe('Feature: get cash from an ATM:', function() { beforeEach(function(done) { world().getATM().cashAvailable(Number(cash), done); }); // Any other background set up .... context('Scenario: success', function() { beforeEach(function() { // Any scenario specific setup, none in this case }); describe('When the user asks the ATM for 500', function() { beforeEach(function(done) { world().getATM().requestCash(500, done); }); }); }); });
var world; before(function(done) { world = new World(done); }); beforeEach(function(done) { world.getDB().eraseAll().createDefaultData().then(done, done); }); afterEach(function(done) { world.getATM().shutdown().fin(db.shutdown.bind(db)).fin(done); }); module.exports = function() { return world; };
// package.json { // .... "scripts": { "test": "mocha -u bdd -R dot --recursive test/unit/", "bdd": "mocha -u bdd -R dot --recursive test/bdd/" }, "devDependencies": { "mocha": "~1.8.1" } }
$> NODE_ENV=test npm run-script bdd
grunt.initConfig({ // .... simplemocha: { options: { timeout: 3000, ui: 'bdd', reporter: 'dot' }, unit: { files: { src: ['test/unit/**/*.js'] } }, bdd: { files: { src: ['test/bdd/**/*.js'] } } } });
$> NODE_ENV=test grunt simplemocha:bdd
Works for either Mocha or CucumberJS
100s of tests per second
Web Page icon from http://commons.wikimedia.org/wiki/File:1328101978_Web-page.png
// karma.conf.js reporters = ['dots', 'junit']; port = 9876; runnerPort = 9100; logLevel = LOG_DEBUG; browsers = ['Chrome', 'Firefox', 'PhantomJS']; singleRun = false; //....
// karma.conf.js (continued) var BASE_DIR = 'src/test/bdd/'; files = [ MOCHA, MOCHA_ADAPTER, {pattern: BASE_DIR + "vendor/**/*.js", watched: false}, {pattern: 'node_modules/chai/chai.js', watched: false}, {pattern: BASE_DIR + '/features/**/*.js', watched: false}, {pattern: 'www/**', watched: false, included: false} ];
Scenario: success When the user enters 500 into the "amount" field And the user press the submit button Then the ATM will have 500 And the user's account will have 4500 And the ATM will provide 500 in cash
Trivial change in UI -> Multiple changes in Gherkin and Steps
// ... this.When("the user asks the ATM for $cash",function(cash, done){ browser.fill('amount', Number(cash)) .pressButton('submit') .then(done, done); }); // ...
Trivial change in UI -> Multiple Steps affected
// ... describe('When the user asks the ATM for 500', function() { beforeEach(function(done) { browser.fill('amount', 500) .pressButton('submit') .then(done, done); }); // ... }); // ...
var Browser = require('zombie'); module.exports = function() { var browser = new Browser({ site:baseURL }); this.userIsLoggedIn = function(userId) { browser.cookies('.atm.com', '/') .set(SESSION_COOKIE, newSessionToken(userId)); }; this.visitPage = function(pageName) { return browser.visit('/' + pageName + '.html'); }; this.requestCash = function(money) { return browser.fill('amount', money).pressButton('submit'); }; this.displayedCurrentAmount = function() { return Number(browser.text('.current-amount')); }; };
Isolates the SUT
var bdd = bdd || { count: 0 }; bdd.UI = function () { var childDOC, self = this, id='fr' + (bdd.count++); this.visitPage = function(pageName, done) { destroyIframeIfExists(); $('body').append('<iframe id="'+frameId+'"></iframe>'); $('#' + frameId).load(function () { childDOC = this.contentDocument; setTimeout(done, 500); // Wait for app to initialize }); $('#fr1').attr('src', '/' + pageName + '.html'); }; // ... };
// ... this.requestCash = function(money, done) { $('input[name="amount"]', childDOC).val(text) .trigger('keyup') .trigger('change'); $('button[type="submit"]', childDOC).focus().click(); done(); }; // ...
this.requestCash = function(money, done) { $('input[name="amount"]', childDOC).simulate("key-sequence", { sequence: String(money), callback: function () { $('button[type="submit"]', childDOC).simulate('click'); done(); } }); };
Using jQuery simulate https://github.com/eduardolundgren/jquery-simulate
and jQuery simulate extensions https://github.com/j-ulrich/jquery-simulate-ext
// ... this.displayedCurrentAmount = function() { return Number($('.current-amount', childDOC).first().text()); }; // ...
bdd.definePageObject('ui', { requestCash: { selector: 'form.request-cash', fields: { amount: 'input[name="amount"]' }, submit: 'button[type="submit"]' }, displayedCurrentAmount: { selector: '.current-amount', conversor: Number } }); bdd.newPageObject('ui').visitPage('atm') .requestCash({amount: 500}) .then(done, done);
It could be nice to get rid of all the boilerplate!
module.exports = function() { this.World = require('./support/World'); this.Given("the ATM has $cash", function(cash,done){ this.ATM().cashAvailable(Number(cash), done); }); this.When("the user asks the ATM for $cash", function(cash,done){ this.ATM().requestCash(Number(cash), done); }); this.Then("the user's account will have $cash", function(cash, done) { this.DB().accountFor(this.userId).then(function(acc) { expect(cash).to.be.eql(acc.cash); }).then(done, done); }); };
You can start cheap & simple
You can evolve to a more complex testing platform
For CucumberJS with page object using ZombieJS: http://bit.ly/ZKApZH
For Mocha, Karma & the iframe page object: http://bit.ly/ysoZHx
Warning: There is still some code to clean up there!