Dans un précédent article nous avons vu comment écrire des tests unitaires JS Model & Collection sur la todo liste backbone.

On va poursuivre ici par les tests des composants Backbone de type View.

Mocha est le framework de test utilisé, complété par Chai pour les assertions et Sinon.JS pour les doublures (spies, stub, mock).

L’environnement d’exécution nécessite seulement Node.js, pas besoin de browser pour lancer les tests unitaires, nous allons voir comment exécuter des assertions sur le dom dans Node.js.

L’exemple complet est disponible sur Github sur la branche webdriverio-it-tests (voir le README pour exécuter les tests unitaires).

Configuration

La configuration grunt (voir article TU model) est responsable d’exécuter le helper global main.js

Mise en place de Chai et Sinon dans le helper main.js

 var chai = require('chai');
 var sinon = require('sinon');
 var sinonChai = require('sinon-chai');
 chai.use(sinonChai);

 global.sinon = sinon;
 global.expect = chai.expect;
...

Mise en place de jsdom dans le helper main.js

 var jsdom = require('jsdom');
 var fs = require('fs');
 var markup = fs.readFileSync('app/index.html');

 global.window = jsdom.jsdom(markup).defaultView;

jsdom est une implémentation javascript du dom qui permet de se passer d’un browser.

Tests unitaires du composant Todo View

describe('TodoView', function() {
var Todo = requirejs('models/todo');
var TodoView = requirejs('views/todoView');
var Common = requirejs('common');
var $ = requirejs('jquery');
var todo, todoView;
beforeEach(function() {
todo = new Todo();
todoView = new TodoView({model:todo});
});
afterEach(function() {
todoView.remove();
});
it('tagName should equal li', function() {
expect(todoView.tagName).to.equal('li');
});
it('render view', function() {
var view = new TodoView({model: new Todo({title: 'todo1', completed: true})});
view.render();
expect(view.$input.val()).to.equal('todo1');
expect(view.$el.hasClass('completed')).to.be.true;
});
describe('listeners', function() {
it('when model title change then render view', function() {
todo.set('title','title updated');
expect(todoView.$input.val()).to.equal('title updated');
});
it('when model destroy then remove view', function() {
var spy = sinon.spy(todoView,'remove');
todoView.initialize();
todo.destroy();
expect(spy).have.been.called;
});
it('when model trigger visible then view has class hidden', function() {
Common.TodoFilter = 'completed';
todo.trigger('visible');
expect(todoView.$el.hasClass('hidden')).to.be.true;
});
});
describe('events', function() {
var createEvent = function(eventName, keyCode) {
var e = $.Event(eventName);
e.which = keyCode;
return e;
};
var setupOnEditModeWithValue = function(val) {
todo.set('title',val);
todoView.$el.find('label').dblclick();
};
describe('when click', function() {
beforeEach(function() {
todoView.render();
});
it('class toggle then model completed', function() {
var spy = sinon.stub(todo,'save');
todoView.$el.find('.toggle').click();
expect(todo.get('completed')).to.be.true;
expect(spy).have.callCount(1);
});
it('class destroy then destroy model', function() {
var spy = sinon.spy(todo,'destroy');
todoView.$el.find('.destroy').click();
expect(spy).have.callCount(1);
});
});
it('when double click label then switch to edit mode', function() {
todo.set('title','todo1');
todoView.$el.find('label').dblclick();
expect(todoView.$el.hasClass('editing')).to.be.true;
expect(todoView.$el.find('.edit:focus')).to.have.length(1);
});
describe('when keypress class edit', function() {
it('on enter then save', function() {
var spy = sinon.stub(todo,'save');
setupOnEditModeWithValue('todo1');
todoView.$el.find('.edit').trigger(createEvent('keypress', Common.ENTER_KEY));
expect(todoView.$el.hasClass('editing')).to.be.false;
expect(spy).have.callCount(1);
});
describe('but not save', function() {
var spy;
beforeEach(function() {
spy = sinon.spy(todo,'save');
});
[{value: '', description: 'empty'},
{value: ' ', description: 'with space'}]
.forEach(function(test) {
it('when enter and title '+ test.description, function() {
setupOnEditModeWithValue(test.value);
todoView.$el.find('.edit').trigger(createEvent('keypress', Common.ENTER_KEY));
expect(todoView.$el.hasClass('editing')).to.be.false;
});
});
it('when not press enter', function() {
setupOnEditModeWithValue('todo1');
todoView.$el.find('.edit').trigger(createEvent('keypress', 65));
expect(todoView.$el.hasClass('editing')).to.be.true;
});
afterEach(function() {
expect(spy).have.callCount(0);
});
});
});
it('when blur class edit then save', function() {
var spy = sinon.stub(todo,'save');
setupOnEditModeWithValue('todo1');
todoView.$input.focusout();
expect(todoView.$el.hasClass('editing')).to.be.false;
expect(spy).have.callCount(1);
});
describe('when keydown class edit', function() {
beforeEach(function() {
setupOnEditModeWithValue('todo1');
todoView.$input.val('todo1 updated');
});
it('on escape key then revert title', function() {
todoView.$el.find('.edit').trigger(createEvent('keydown', Common.ESC_KEY));
expect(todoView.$input.val()).to.equal('todo1');
expect(todoView.$el.hasClass('editing')).to.be.false;
});
it('but no escape key fire no changes', function() {
todoView.$el.find('.edit').trigger(createEvent('keydown', 65));
expect(todoView.$input.val()).to.equal('todo1 updated');
expect(todoView.$el.hasClass('editing')).to.be.true;
});
});
});
describe('is hidden', function() {
var tests = [
{filter: 'completed', completed: false, hidden: true},
{filter: 'completed', completed: true, hidden: false},
{filter: 'pending', completed: false, hidden: false},
{filter: 'pending', completed: true, hidden: true},
{filter: '', completed: false, hidden: false},
{filter: '', completed: true, hidden: false},
];
tests.forEach(function(test) {
it('when filter '+ (test.filter===''?'empty':test.filter)+' and todo '+(test.completed?'':'not ')+'completed then hidden '+test.hidden, function() {
Common.TodoFilter = test.filter;
todo.set('completed',test.completed);
expect(todoView.isHidden()).to.equal(test.hidden);
});
});
});
});
  • Les appels imbriqués de describe permettent d’organiser les tests et améliorer la lisibilité du reporting
  • Le découpage des tests est fait selon les listeners puis les events
  • Les assertions se font sur le dom de l’élément de la view
  • Les stubs Sinon.JS sont utilisés pour substituer une implémentation vide aux méthodes du model afin de ne pas déclencher d’appels à l’api REST
  • Les spy Sinon.JS sont utilisés pour vérifier l’appel des méthodes du model
  • Les tests paramétrés permettent de couvrir tous les cas de la méthode isHidden en évitant la duplication

 

Tests unitaires du composant App View

describe('AppView', function() {
var todos = requirejs('collections/todos');
var AppView = requirejs('views/appView');
var Common = requirejs('common');
var $ = requirejs('jquery');
var appView;
var spyFetch;
before(function() {
spyFetch = sinon.stub(todos,'fetch');
appView = new AppView();
});
beforeEach(function() {
todos.reset();
});
after(function() {
appView.remove();
});
describe('new AppView', function() {
it('el id should equal todoapp', function() {
expect(appView.el.id).to.equal('todoapp');
});
it('when initialize then fetch', function() {
expect(spyFetch).have.been.called;
});
after(function() {
spyFetch.restore();
});
});
describe('listeners', function() {
it('when add collection then render todo in TodoView', function() {
todos.add({title: 'new todo', completed: false});
expect(appView.$el.find('#todo-list li:last-child .edit').val()).to.equal('new todo');
});
it('when reset collection then render todos in TodoViews', function() {
todos.reset([{title: 'todo1'}, {title: 'todo2'}]);
expect(appView.$el.find('#todo-list li')).to.have.length(2);
});
it('when filter collection then render todos in TodoViews with filter', function() {
todos.set([{title: 'todo1', completed: true}, {title: 'todo2', completed: false}]);
Common.TodoFilter = 'completed';
todos.trigger('filter');
expect(appView.$el.find('#todo-list li.hidden')).to.have.length(1);
expect(appView.$el.find('#todo-list li:not(.hidden) .edit').val()).to.equal('todo1');
})
});
describe('events', function() {
describe('when keypress id new-todo ', function() {
var spyCreate;
var createEvent = function(eventName, keyCode) {
var e = $.Event(eventName);
e.which = keyCode;
return e;
};
before(function() {
spyCreate = sinon.stub(todos, 'create');
});
beforeEach(function() {
appView.$input.val('todo1');
});
afterEach(function() {
spyCreate.reset();
});
it('on enter key then todos create', function() {
appView.$el.find('#new-todo').trigger(createEvent('keypress', Common.ENTER_KEY));
expect(appView.$input.val()).to.be.empty;
expect(spyCreate).to.have.been.calledWith({title: 'todo1', completed: false});
});
it('but no enter key fire no todos create call', function() {
appView.$el.find('#new-todo').trigger(createEvent('keypress', 65));
expect(appView.$input.val()).to.be.equal('todo1');
expect(spyCreate).have.callCount(0);
});
});
it('when click id toggle-all then render all todos as completed', function() {
todos.set([{title: 'todo1'}, {title: 'todo2'}]);
todos.each(function (todo) {
sinon.stub(todo,'save', function() {
todo.set('completed', true);
});
});
appView.$el.find('#toggle-all').click();
expect(appView.$el.find('#todo-list li.completed')).to.have.length(2);
});
});
});
  • Une seule instance de AppView pour tous les tests
  • La collection Todos est ré initialisée à chaque test
  • Les stubs Sinon.JS sont utilisés pour substituer une implémentation du model qui met seulement à jour le model sans déclencher d’appels à l’api REST
  • Les spy Sinon.JS sont utilisés pour vérifier l’appel des méthodes de la collection avec les paramètres attendus

 

Les TU Views + TU Model & Collection permettent donc de couvrir toutes les lignes / branches du code de l’application todo liste en fournissant un effort minimum pour chaque méthode de chaque composant.

L’ajout de tests Web d’intégration en plus des TU permet de construire une stratégie de test complète et une répartition de l’effort de test équilibrée.