Testing controllers

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.

Mocking dependencies

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.

Fake objects

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.

Testing ContactEditor

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();
});
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.15.29.119