Controllers are more complex than test as they have more dependencies than the models, collections, and views. If you explore the code on these objects, you will see that the only dependencies that they have are Backbone and Underscore.
You can test the controllers with all its dependencies, which means that while testing the ContactEditor
controller, you will be testing all the views and models attached to it as the module requires these objects.
That's not good for unit testing as you will end up with integration tests instead. If the Contact
model has a defect, then ContactEditor
will fail, even if it does not have any error in it.
You need to isolate the modules from the mess of other modules. Keep in mind that you should trust your libraries as they will already have their test suites. We need a mechanism to fake the dependencies of a module.
With dependency injection, you can overwrite the require()
function, instead of loading the script that points, in order to use a fake object. This will guarantee that the code that is being tested is isolated and its behavior is predictable for unit testing.
There are two main choices to mock dependencies in Node: rewire
and proxyquireify
; with these libraries, you can overwrite the original dependencies of a module in order to use a fake version instead.
With Browserify, you should have proxyquireify
. Install it with npm, as follows:
$ npm install --save-dev proxyquirefy
Once the library is installed, we need to add a proper configuration in the Karma configuration file:
// ... browserify: { debug: true, plugin: ['proxyquireify/plugin'], transform: ['jstify'], extensions: ['.js', '.tpl'] }, // ...
You should initialize proxyquireify
before using it. As proxyquireify
overwrites the original require()
function, it should be initialized before being used. The initialization function returns a function object that is similar to the original require()
function; however, with the the extra functionality of fake dependencies, as shown in the following:
var proxyquire = require('proxyquireify')(require);
The proxyquire
object can be used to load modules:
var ContactViewer = proxyquire('./contacts/contactViewer');
When you load a module with proxyquireify
, you can use a second argument to overwrite the original dependencies. It is an object where the keys are the name of the dependencies and the values are the object that will substitute the original dependency:
var targetFile = '../../app/js/apps/contacts/contactViewer'; var fakes = { './views/ContactView': Backbone.View } var ContactViewer = proxyquire(targetFile, fakes);
This configuration will replace the ContactView
object with an empty Backbone.View
object so that when testing the ContactViewer
object, the module will not load the original ContactView
module.
A fake object is a simple object that has the same functions as an original one; however, with a predictable behavior so that you can use fake objects to isolate the module under test. For example, all our controllers depend on the App
object to work; however, it is not a good idea to use the real App
object for the purpose of testing. If the App
object has an error, then the controller test will fail.
A fake for the App
object is as shown in the following:
// spec/fakes/app.js 'use strict'; var fakeRouter = { navigate: jasmine.createSpy() }; var FakeApp = { router: fakeRouter, notifySuccess(message) { this.lastSuccessMessage= message; }, notifyError(message) { this.lastErrorMessage = message; }, reset() { deletethis.lastSuccessMessage; deletethis.lastErrorMessage; this.router.navigate = jasmine.createSpy(); } }; _.extend(FakeApp, Backbone.Events); module.exports = FakeApp;
This simple object can simulate to be the real App
object, as you can see the object does nothing; however, it will be useful in the next section for testing the ContactEditor
controller.
Regions can also be faked in order to remove all the overheads of the original region:
// spec/fakes/region.js 'use strict'; class FakeRegion { show(view) { view.render(); } } module.exports = FakeRegion;
It is very simple, just to render the view that is passed to it.
The
ContactEditor
controller's responsibility is to render the necessary views in order to allow the user to update or create new contacts. It is closely related to many views and the Contact
model.
We are going to use proxyquireify
to isolate the ContactEditor
controller and instead of using the real objects, we will fake most of them. The first test is to check whether the subapplication is rendered in the right region:
// spec/apps/contacts/contactEditor.js var proxyquery = require('proxyquireify')(require); var Backbone = require('backbone'); var FakeRegion = require('../../fakes/region'); var fakes = { './views/contactPreview': Backbone.View, './views/phoneListView': Backbone.View, './views/emailListView': Backbone.View, './collections/phoneCollection': Backbone.Collection, './collections/emailCollection': Backbone.Collection }; var ContactEditor = proxyquery('../../../app/js/apps/contacts/contactEditor', fakes); describe('Contact editor', () => { var fakeContact; var editor; var region; beforeEach(() => { region = new FakeRegion(); editor = new ContactEditor({region}); fakeContact = new Backbone.Model({ name: 'John Doe', facebook: 'https://www.facebook.com/john.doe', twitter: '@john.doe', github: 'https://github.com/johndoe', google: 'https://plus.google.com/johndoe' }); }); describe('showing a contact editor', () => { it('renders the editor in the given region', () => { spyOn(region, 'show').and.callThrough(); editor.showEditor(fakeContact); expect(region.show).toHaveBeenCalled(); }); }); });
We are faking almost all the views of the ContactEditor
controller, we don't need the real views as we are not testing the output HTML, that's a job for view testing. The only view that is not faked is the FormLayout
view:
// spec/fakes/formLayout.js 'use strict'; var Common = require('../../app/js/common'); class FakeFormLayout extends Common.Layout { constructor(options) { super(options); this.template = '<div class="phone-list-container" />' + '<div class="email-list-container" />'; this.regions = { phones: '.phone-list-container', emails: '.email-list-container' }; } } module.exports = FakeFormLayout;
Then add the fake, as follows:
var FakeFormLayout = require('../../fakes/formLayout'); var fakes = { './views/contactPreview': Backbone.View, './views/phoneListView': Backbone.View, './views/emailListView': Backbone.View, './views/contactForm': FakeFormLayout, './collections/phoneCollection': Backbone.Collection, './collections/emailCollection': Backbone.Collection }; // ...
In the ContactEditor
controller, we are listening for avatar:selected
of the ContactPreview
view, we should ensure that the event is handled correctly. However, we have a problem, we cannot access the view instance. To make the controller testable, it is a common practice to put the views as attributes of the controller, as shown in the following code:
class ContactEditor { // ... showEditor(contact) { // Data var phonesData = contact.get('phones') || []; var emailsData = contact.get('emails') || []; this.phones = new PhoneCollection(phonesData); this.emails = new EmailCollection(emailsData); // Create the views this.layout = new ContactFormLayout({model: contact}); this.phonesView = new PhoneListView({ collection: this.phones }); this.emailsView = new EmailListView({ collection: this.emails }); this.contactForm = new ContactForm({model: contact}); this.contactPreview = new ContactPreview({ controller: this, model: contact }); // Render the views this.region.show(this.layout); this.layout.getRegion('form').show(this.contactForm); this.layout.getRegion('preview').show(this.contactPreview); this.contactForm.getRegion('phones').show(this.phonesView); this.contactForm.getRegion('emails').show(this.emailsView); this.listenTo(this.contactForm, 'form:save', this.saveContact); this.listenTo(this.contactForm, 'form:cancel', this.cancel); this.listenTo(this.contactForm, 'phone:add', this.addPhone); this.listenTo(this.contactForm, 'email:add', this.addEmail); this.listenTo(this.phonesView, 'item:phone:deleted', (view, phone) => { this.deletePhone(phone); }); this.listenTo(this.emailsView, 'item:email:deleted', (view, email) => { this.deleteEmail(email); }); // When avatar is selected, we can save it inmediatly if the // contact already exists on the server, otherwise just // remember the file selected this.listenTo(this.contactPreview, 'avatar:selected', blob => { this.avatarSelected = blob; if (!contact.isNew()) { this.uploadAvatar(contact); } }); } // ... }
With this change, we can make the proper test, it verifies that the avatarSelected
property is set when the contactPreview
view selects an image:
it('binds the avatar:selected event in the contact preview', () => { var expectedBlob = new Blob(['just text'], { type: 'text/plain' }); editor.showEditor(fakeContact); // Fake the uploadAvatar method to prevent side effects editor.uploadAvatar = jasmine.createSpy(); editor.contactPreview.trigger('avatar:selected', expectedBlob); expect(editor.avatarSelected).toEqual(expectedBlob); });
The core functionality of the ContactEditor
controller is to save the contact properly when the user clicks on the Save button, as follows:
describe('Contact editor', () => { // ... describe('saving a contact', () => { beforeEach(() => { jasmine.Ajax.install(); // Fake the contact url, it is not important here fakeContact.url = '/fake/contact'; // Fake upload avatar, we are not testing this feature editor.uploadAvatar = function(contact, options) { options.success(); }; editor.showEditor(fakeContact); }); afterEach(() => { jasmine.Ajax.uninstall(); FakeApp.reset(); }); } }
In this test case, the controller will call the save()
method in the model to save the contact and Backbone will make an Ajax call to the server. When you are testing, you should not make real server connections as that will make your tests slow and prone to failing.
With the jasmine-ajax
plugin, you can fake the Ajax calls so that you will have a total control of how the test behaves. You will need to install the package first:
$ npm install --save-devkarma-jasmine-ajax
Then, update the configuration of Karma to include the plugin, as follows:
frameworks: ['browserify', 'jasmine-ajax', 'jasmine'],
The plugin overwrites the original XMLHttpRequest
object, therefore, it's important to initialize the Ajax plugin before starting your test and restore the original object once your test is done.
In the beforeEach()
function, we will initialize the plugin by calling jasmine.Ajax.install()
and restore the original XMLHttpRequest
object with jasmine.Ajax.uninstall()
in afterEach()
.
When your application makes an Ajax call, the plugin will catch the request and you can then inspect the request or fake the response, as follows:
it('shows a success message when the contact is saved', () => { editor.saveContact(fakeContact); jasmine.Ajax.requests.mostRecent().respondWith({ status: '200', contentType: 'application/json', responseText: '{}' }); expect(FakeApp.lastSuccessMessage).toEqual('Contact saved'); expect(FakeApp.router.navigate) .toHaveBeenCalledWith('contacts', true); });
In the preceding test, we saved the contact and faked an HTTP 200
response. When this happens, the application will show a success message and redirect the application to the contact list.
If the server responds with an error, then the application will show an error message and not make a redirection to the contact list:
it('shows an error message when the contact cant be saved', () => { editor.saveContact(fakeContact); jasmine.Ajax.requests.mostRecent().respondWith({ status: '400', contentType: 'application/json', responseText: '{}' }); expect(FakeApp.lastErrorMessage) .toEqual('Something goes wrong'); expect(FakeApp.router.navigate) .not.toHaveBeenCalled(); });
Another thing that the saveContact()
method does is to set the phones
and emails
attributes in the contact model. The test will ensure that the attributes are sent to the server correctly, as shown in the following code:
it('saves the model with the phones and emails added', () => { var expectedPhone = { description: 'test', phone: '555 5555' }; var expectedEmail = { description: 'test', phone: '[email protected]' }; editor.phones = new Backbone.Collection([expectedPhone]); editor.emails = new Backbone.Collection([expectedEmail]); editor.saveContact(fakeContact); var requestText = jasmine.Ajax.requests.mostRecent().params; var request = JSON.parse(requestText); expect(request.phones.length).toEqual(1); expect(request.emails.length).toEqual(1); expect(request.phones).toContain(expectedPhone); expect(request.emails).toContain(expectedEmail); });
We are setting a list of phones
and emails
and then test whether the server receives the right request.
If the contact is not valid, then the controller will not send anything to the server:
it('does not save the contact if the model is not valid', () => { // Emulates an invalid model fakeContact.isValid = function() { return false; }; editor.saveContact(fakeContact); expect(jasmine.Ajax.requests.count()).toEqual(0); });
The ContactEditor
object should upload the avatar image only if the model is new. If the model not is new, then the avatar is uploaded immediately when the user selects the image:
it('uploads the selected avatar if model is new', () => { // Emulates a new model fakeContact.isNew= function() { return true; }; editor.uploadAvatar = jasmine.createSpy('uploadAvatar'); editor.saveContact(fakeContact); jasmine.Ajax.requests.mostRecent().respondWith({ status: '200', contentType: 'application/json', responseText: '{}' }); expect(editor.uploadAvatar).toHaveBeenCalled(); }); it('does not upload the selected avatar if model is not new', () => { // Emulates a not new model fakeContact.isNew= function() { return false; }; editor.uploadAvatar = jasmine.createSpy('uploadAvatar'); editor.saveContact(fakeContact); jasmine.Ajax.requests.mostRecent().respondWith({ status: '200', contentType: 'application/json', responseText: '{}' }); expect(editor.uploadAvatar).not.toHaveBeenCalled(); });
3.15.29.119