Baby steps to Backbone.js: Unit testing of views
Previous time, we’ve implemented a Backbone model and wrote some meaningful tests for it.
Next very important Backbone’s entry to test is View.
Views are central concept in framework. Views are actually ones who do stuff. We don’t have Controller in Backbone, but according to Controller initial idea as user input handler, Backbone views following exactly the same architectural goal, so they could be treaded as controllers in some way.
What to test?
You should focus on such things:
- Initialization - test that view is provided with all required inputs like: model or collection, localization texts, different options. If view is not able, say, render without some option, you should test that exception is thrown.
- Rendering - test that required html appeared in view. I do not create those very strict, since they would be to fragile in case of markup changes, but still I check for major DOM elements are present and have right styles.
- Events - test that view is correctly handle DOM events.
- Model changes and persistence - test that changes in view are correctly propagated in model. If view is about to persist the model, that could tested as well.
I see tests of views as kind of integration test. You communicate to DOM, listening to events, updating models or changing DOM elements state. It’s typically that view tests are catching most regression issues.
How to test?
Fortunately, the Backbone views are designed in very testable way. You don’t need any special HTML on test page, since view holding all it’s DOM structure inside this.el
. By means of jQuery, it’s easy to change DOM values or trigger events.
Since view also holds reference to model, it’s easy to check model’s attributes changes or spying on particular models methods.
First red test,
describe('FeedbackFormView.js spec', function () {
var view, model;
beforeEach(function () {
view = new FeedbackFormView();
});
describe('when view is constructing', function () {
it ('should exist', function () {
expect(view).toBeDefined();
});
});
Here we basically testing, that FeedbackFormView should exist, so as soon as function is in place, test should be green.
FeedbackFormView could not exist without a model as well as default feedback text, that should be used as initial value of textbox. In the language of TDD, that means:
describe('when view is initialized', function () {
describe('without model', function () {
it('should throw exception', function () {
expect(function () {
new FeedbackFormView();
}).toThrow(new Error('model is required'));
});
});
describe('without default feedback', function () {
it('should throw exception', function () {
expect(function () {
new FeedbackFormView({model: new Backbone.Model() });
}).toThrow(new Error('feedback is required'));
});
});
});
To turn from Red to Green, let’s add initialize
function to view,
initialize: function (options) {
if (!this.model) {
throw new Error('model is required');
}
if (!(options && options.feedback)) {
throw new Error('feedback is required');
}
this.feedback = options.feedback;
},
Btw, after that change it’s required to fix previous test, since it start to fail.
describe('FeedbackFormView.js spec', function () {
var view, model;
beforeEach(function () {
view = new FeedbackFormView({model: new Feedback(), feedback: 'TDD is awesome..' });
});
describe('when view is constructing', function () {
it ('should exist', function () {
expect(view).toBeDefined();
});
});
Now, let’s test how rendering works.
describe('when view is rendered', function () {
beforeEach(function () {
view.render();
});
it ('should email field be empty', function () {
expect(view.$el.find('input#email')).toHaveValue('');
});
it ('should website field be empty', function () {
expect(view.$el.find('input#website')).toHaveValue('');
});
it ('should feedback field with default feedback', function () {
expect(view.$el.find('textarea#feedback')).toHaveValue('TDD is awesome..');
});
});
Here and after I’m using very nice Jasmine plugin, called jasmine-jquery. It adds a number of matchers, very useful for testing jQuery objects.
We’ve tested initialization and rendering, now let’s test last aspect, model changes and persistence.
Suppose a user inputs nothing, erases default feedback and presses submit button. Expected behavior is expressed with this test:
describe('when form is submitted', function () {
describe('no inputs are filled', function () {
beforeEach(function () {
view.$el.find('#email').val('').trigger('change');
view.$el.find('#feedback').val('').trigger('change');
});
beforeEach(function () {
view.$el.find('#submit').trigger('click');
});
it('email field should be invalidated', function () {
expect(view.$el.find('.control-group.email')).toHaveClass('error');
});
it('feedback field should be invalidated', function () {
expect(view.$el.find('.control-group.feedback')).toHaveClass('error');
});
it('website field should be valid', function () {
expect(view.$el.find('.control-group.website')).not.toHaveClass('error');
});
});
That’s cool. After those tests are passing, we can test that if only email is filled, but feedback is still empty, we are not able to submit the form. By analogy of previous test,
describe('only email field filled', function () {
beforeEach(function () {
view.$el.find('#email').val('a@a.com').trigger('change');
view.$el.find('#feedback').val('').trigger('change');
});
beforeEach(function () {
view.$el.find('#submit').trigger('click');
});
it('email field should be valid', function () {
expect(view.$el.find('.control-group.email')).not.toHaveClass('error');
});
it('feedback field should be invalidated', function () {
expect(view.$el.find('.control-group.feedback')).toHaveClass('error');
});
it('website field should be valid', function () {
expect(view.$el.find('.control-group.website')).not.toHaveClass('error');
});
});
And finally, if view is filled correctly,
describe('email and feedback filled', function () {
beforeEach(function () {
spyOn(view.model, 'save').andCallThrough();
});
beforeEach(function () {
view.$el.find('#email').val('a@a.com').trigger('change');
view.$el.find('#feedback').val('some feedback').trigger('change');
});
beforeEach(function () {
view.$el.find('#submit').trigger('click');
});
it('should show no errors', function () {
expect(view.$el.find('.error').length).toBe(0);
});
it('should save model', function () {
expect(view.model.save).toHaveBeenCalled();
});
});
Here, we test 2 things: first, that no validation errors appeared on form and second that save
method of model is called. Jasmine built-in spy framework is used here. You can setup on any function of any object and then verify that function has (or has not) been called.
I’m skipping the implementation of that view, but you can find the code of application in gist.
Conclusions
While you typically start with defining and testing models in your application, view is integration test that helps to test actual behavior of application, depending on model states and events. You should define how “deep” you want to test the view, but my proposal is to focus on initialization, rendering, changes (validation) and persistence.
Previous time, we’ve implemented a Backbone model and wrote some meaningful tests for it.
Next very important Backbone’s entry to test is View.
Views are central concept in framework. Views are actually ones who do stuff. We don’t have Controller in Backbone, but according to Controller initial idea as user input handler, Backbone views following exactly the same architectural goal, so they could be treaded as controllers in some way.
What to test?
You should focus on such things:
- Initialization - test that view is provided with all required inputs like: model or collection, localization texts, different options. If view is not able, say, render without some option, you should test that exception is thrown.
- Rendering - test that required html appeared in view. I do not create those very strict, since they would be to fragile in case of markup changes, but still I check for major DOM elements are present and have right styles.
- Events - test that view is correctly handle DOM events.
- Model changes and persistence - test that changes in view are correctly propagated in model. If view is about to persist the model, that could tested as well.
I see tests of views as kind of integration test. You communicate to DOM, listening to events, updating models or changing DOM elements state. It’s typically that view tests are catching most regression issues.
How to test?
Fortunately, the Backbone views are designed in very testable way. You don’t need any special HTML on test page, since view holding all it’s DOM structure inside this.el
. By means of jQuery, it’s easy to change DOM values or trigger events.
Since view also holds reference to model, it’s easy to check model’s attributes changes or spying on particular models methods.
First red test,
describe('FeedbackFormView.js spec', function () { var view, model; beforeEach(function () { view = new FeedbackFormView(); }); describe('when view is constructing', function () { it ('should exist', function () { expect(view).toBeDefined(); }); });
Here we basically testing, that FeedbackFormView should exist, so as soon as function is in place, test should be green.
FeedbackFormView could not exist without a model as well as default feedback text, that should be used as initial value of textbox. In the language of TDD, that means:
describe('when view is initialized', function () { describe('without model', function () { it('should throw exception', function () { expect(function () { new FeedbackFormView(); }).toThrow(new Error('model is required')); }); }); describe('without default feedback', function () { it('should throw exception', function () { expect(function () { new FeedbackFormView({model: new Backbone.Model() }); }).toThrow(new Error('feedback is required')); }); }); });
To turn from Red to Green, let’s add initialize
function to view,
initialize: function (options) { if (!this.model) { throw new Error('model is required'); } if (!(options && options.feedback)) { throw new Error('feedback is required'); } this.feedback = options.feedback; },
Btw, after that change it’s required to fix previous test, since it start to fail.
describe('FeedbackFormView.js spec', function () { var view, model; beforeEach(function () { view = new FeedbackFormView({model: new Feedback(), feedback: 'TDD is awesome..' }); }); describe('when view is constructing', function () { it ('should exist', function () { expect(view).toBeDefined(); }); });
Now, let’s test how rendering works.
describe('when view is rendered', function () { beforeEach(function () { view.render(); }); it ('should email field be empty', function () { expect(view.$el.find('input#email')).toHaveValue(''); }); it ('should website field be empty', function () { expect(view.$el.find('input#website')).toHaveValue(''); }); it ('should feedback field with default feedback', function () { expect(view.$el.find('textarea#feedback')).toHaveValue('TDD is awesome..'); }); });
Here and after I’m using very nice Jasmine plugin, called jasmine-jquery. It adds a number of matchers, very useful for testing jQuery objects.
We’ve tested initialization and rendering, now let’s test last aspect, model changes and persistence.
Suppose a user inputs nothing, erases default feedback and presses submit button. Expected behavior is expressed with this test:
describe('when form is submitted', function () { describe('no inputs are filled', function () { beforeEach(function () { view.$el.find('#email').val('').trigger('change'); view.$el.find('#feedback').val('').trigger('change'); }); beforeEach(function () { view.$el.find('#submit').trigger('click'); }); it('email field should be invalidated', function () { expect(view.$el.find('.control-group.email')).toHaveClass('error'); }); it('feedback field should be invalidated', function () { expect(view.$el.find('.control-group.feedback')).toHaveClass('error'); }); it('website field should be valid', function () { expect(view.$el.find('.control-group.website')).not.toHaveClass('error'); }); });
That’s cool. After those tests are passing, we can test that if only email is filled, but feedback is still empty, we are not able to submit the form. By analogy of previous test,
describe('only email field filled', function () { beforeEach(function () { view.$el.find('#email').val('a@a.com').trigger('change'); view.$el.find('#feedback').val('').trigger('change'); }); beforeEach(function () { view.$el.find('#submit').trigger('click'); }); it('email field should be valid', function () { expect(view.$el.find('.control-group.email')).not.toHaveClass('error'); }); it('feedback field should be invalidated', function () { expect(view.$el.find('.control-group.feedback')).toHaveClass('error'); }); it('website field should be valid', function () { expect(view.$el.find('.control-group.website')).not.toHaveClass('error'); }); });
And finally, if view is filled correctly,
describe('email and feedback filled', function () { beforeEach(function () { spyOn(view.model, 'save').andCallThrough(); }); beforeEach(function () { view.$el.find('#email').val('a@a.com').trigger('change'); view.$el.find('#feedback').val('some feedback').trigger('change'); }); beforeEach(function () { view.$el.find('#submit').trigger('click'); }); it('should show no errors', function () { expect(view.$el.find('.error').length).toBe(0); }); it('should save model', function () { expect(view.model.save).toHaveBeenCalled(); }); });
Here, we test 2 things: first, that no validation errors appeared on form and second that save
method of model is called. Jasmine built-in spy framework is used here. You can setup on any function of any object and then verify that function has (or has not) been called.
I’m skipping the implementation of that view, but you can find the code of application in gist.
Conclusions
While you typically start with defining and testing models in your application, view is integration test that helps to test actual behavior of application, depending on model states and events. You should define how “deep” you want to test the view, but my proposal is to focus on initialization, rendering, changes (validation) and persistence.