AngularJS and Rails Tutorial: part 4.5 unit testing bootstrap modals with Karma

Part 4.5 of a tutorial that connects AngularJS to a Rails backend. This post focuses on creating karma unit tests for the edit controller and the modal dialog, including mocking the modal dialog.  The previous post was ngGrid and a modal popup, the next post is New and Delete.  You can also find the index of the posts in the tutorial, or hit the tutorials menu at the top and select the Rails 3 tutorial.

I’ve split this content out into a separate post for two reasons.  Firstly, at time of writing this is incomplete, I still have an outstanding item on verifying the parameters passed in as promises.  Secondly, it’s going to be reasonably complicated once it’s done, and I’m assuming that some people will just want to get their Angular app going, and skip over the testing.  So, a separate page.

There is a newer, rails 4 and newer angularJS, version of this tutorial.  It is also more complete and has a nicer UI that doesn’t use modal windows, which is probably a better choice for anyone starting fresh today.  The first page in that tutorial is here, and the index here.

If you haven’t completed the previous tutorial, you can find the code at github: tutorial_4.

We have a new controller, and we have new methods on our old controller.  Let’s sort out some tests in club.spec.js, using karma and jasmine. The content in this tutorial replaces the entire existing content of club.spec.js with a more sophisticated set of tests.

We’re going to first work on tests for the the base club controller. We’ll create a mock dialog to test this against, and we’re also going to use the standard mock httpBackend service so that we aren’t connecting to our backend, as unit tests aren’t supposed to go through to the backend. We’re basing some of the process here on information from three pages:

There is still an outstanding item to verify the club that’s passed in to the dialog, I haven’t worked out how to resolve the “resolve” section of the dialog parameters to verify that the correct club is passed in. I’ll update once I work that out (and if anyone knows how to do that, please leave a comment). In the mean-time, I have a question open on stackoverflow on how to resolve that promise.

We’re also going to use strong validation of our http traffic – so we’re validating each call to the server happens where we expect, and that no spare calls happen, following the expect syntax from the httpBackend documentation.

A final consideration is how we factor and group our code, and therefore which bits we have to retype, and which bits are automatically shared through the beforeEach clauses.  We’ve chosen to create one outer describe clause for the whole test file, with our custom mocks instantiated in the beforeEach for that describe block.  The controllers themselves are declared separately in a describe block for the base controller, and a different describe block for the edit controller. The following blocks together form the complete unit test set for this controller as it stands. First, the declarations and mocking of httpBackend:

/**
 * Unit tests for the club functionality
 */
describe( 'Club functionality', function() {
  // mock Application to allow us to inject our own dependencies
  beforeEach(angular.mock.module('league'));

  // create the custom mocks on the root scope
  beforeEach(angular.mock.inject(function($rootScope, _$httpBackend_){
    //create an empty scope
    scope = $rootScope.$new();

    // we're just declaring the httpBackend here, we're not setting up expectations or when's - they change on each test
    scope.httpBackend = _$httpBackend_;

These are really just definitions, we’ve created an empty root scope that we inject into the controller – it would normally expect to receive it’s root scope from app.js. We also pull in the httpBackend mock, which means we can fake traffic with our server rather than actually calling the server. Next, we set up our dialog mock. This doesn’t do much – we rely on the spyOn method to tell us when it gets called, the key thing is that it returns an instance of itself from each method, allowing the application to assign this return to a variable and call it in the same way as the real dialog works:

    // setup a mock for the dialog - when called it returns the value that was input when it was instantiated
    scope.fakeDialog = {
      response: null,
      club: null,
      dialog: function(parameters) {
        this.club = parameters.resolve.club();
        return this;
      },
      open: function(template, controller) {
        return this;
      },
      close: function(parameters) {
        return this;
      },
      then: function(callBack){
        callBack(this.response);
      }
    };

We create a new mock for the dialog.  In general it would be nice if the angular-bootstrap project provided mocks for use, but the advice looks to be that where there isn’t a mock, you create your own.

My understanding is that a  mock is just an object that has the exact same function calls (methods) as the real service, and that can be injected in place of that service.  In this case our mock is called fakeDialog, it answers to four methods: dialog (which creates the dialog), open (which opens the window), close (which is used later in the edit controller) and then, which is replacing the promise callback with an immediate response.

Both the dialog and the open method return the fakeDialog class itself, which is what the real dialog does.  When you call dialog(), you’re essentially calling new(), so you’re expecting to get back a dialog.

When you call open, there’s a bit of trickiness where we chain to the “then” method.  In the real dialog this is actually a promise, but in our mock by having the open() method return the dialog itself, we can then put a then() method on it too, and that lets us avoid that promise when we’re using the mock.

When we call the then method, we expect to pass in a callback function.  If we look at the open() call in our club.js, we’ll see it’s giving a function that we want to call after the dialog is closed using a variable called result that we expect the dialog to provide.  So our then() method needs to call that function, and we’re going to call that method with the “precannedResponse.”  The idea is that in our test case we set fakeDialog.precannedResponse to our test case response value, then when we call it we can trigger our desired action.

Next, we mock the club entity, which is called by the edit controller (and later in this tutorial, by the delete method on the main controller). This does very little, it just provides a stub:

    // setup a mock for the club entity - it handles both $update and $remove methods, and calls the provided callback immediately (no promise needed)
    scope.fakeClub = {
      $update: function(callback) {
        callback(null);
      },
      $remove: function(callback) {
        callback();
      }
    };   
  }));

After each test we verify there are no outstanding http requests and that none of our expected http requests have failed to be called, to confirm that only the http traffic we expect is occurring.

  afterEach(function() {
    scope.httpBackend.verifyNoOutstandingExpectation();
    scope.httpBackend.verifyNoOutstandingRequest();
  });

In the next block we create the tests for the base club controller. We inject the controller itself, passing in our fake dialog.  We also concern ourselves with the response from the ClubRes.query(), and checking it’s correctly handled, so we create a precanned response whenever the clubs.json url is hit. We also drain this request from the queue – so we call scope.$digest to tell it to process asynch events, and we then call scope.httpBackend.flush() to drain the http request. This otherwise has to be done in each individual test, which duplicates code.

  describe( 'Base club controller', function() {  

    beforeEach(angular.mock.inject(function($controller){
      //declare the controller and inject our scope
      $controller('ClubCtrl', {$scope: scope, $dialog: scope.fakeDialog});

      // setup a mock for the resource - instead of calling the server always return a pre-canned response
      scope.httpBackend.expect('GET', '../clubs.json').respond([
        {"contact_officer":"Contact Officer 1","created_at":"2012-02-02T00:00:00Z","date_created":"2012-01-01T00:00:00Z","id":1,"name":"Club 1","updated_at":"2012-03-03T00:00:00Z"},
        {"contact_officer":"Contact Officer 2","created_at":"2012-02-02T00:00:00Z","date_created":"2012-01-01T00:00:00Z","id":2,"name":"Club 2","updated_at":"2012-03-03T00:00:00Z"}
      ]);
      scope.$digest();
      scope.httpBackend.flush();
    }));

We create a block of tests that relate to the initial render, including our first tests, the tests for the initial club render. We’re just checking that we got two rows, and we check the value in one of the rows to make sure it’s kind of correct. We’re choosing not to verify every individual value, we’re figuring that would be a lot like testing the angular framework itself, which seems wasteful, we’re just doing enough that if it were seriously broken we’d notice.

    describe( 'Initial render', function() {
      it('Has two clubs defined', function(){
        expect(scope.clubs.length).toEqual(2);
      });

      it('First club\'s contact officer is as expected', function(){
        expect(scope.clubs[0].contact_officer).toEqual('Contact Officer 1');
      });
    });

You could close all the brackets at this point and run grunt build to see if it works. Or continue until you have the whole file (and deal with any typos or errors all in one go).

Next, we create the tests for the editClub method and the tests for the edit method.  These have two potential outcomes – we click cancel on the dialog, or we click OK. In order to fake the response we use the scope.fakeDialog.response variable that we created when we wrote the fakeDialog mock:

    describe('Other controller methods', function(){
      it('Calls edit on first row, cancel', function() {
        scope.fakeDialog.response = 'cancel';

        // we expect the fakeDialog dialog and open methods to be called, so we spy on them to get the parameters
        spyOn(scope.fakeDialog, "dialog").andCallThrough();
        spyOn(scope.fakeDialog, "open").andCallThrough();

        // call edit
        scope.editClub(scope.clubs[0]);

        // check parameters passed in
        expect(scope.fakeDialog.dialog).toHaveBeenCalledWith({dialogFade: false, resolve: {club: jasmine.any(Function)}});
        expect(scope.fakeDialog.club.contact_officer).toEqual('Contact Officer 1');
        expect(scope.fakeDialog.open).toHaveBeenCalledWith('club/club_edit.tpl.html', 'ClubEditCtrl');
      });

      it('Calls edit on first row, OK', function() {
        scope.fakeDialog.response = 'not-cancel';

        // we expect the fakeDialog dialog and open methods to be called, so we spy on them to get the parameters
        spyOn(scope.fakeDialog, "dialog").andCallThrough();
        spyOn(scope.fakeDialog, "open").andCallThrough();

        // call edit
        scope.editClub(scope.clubs[0]);

        // check parameters passed in
        expect(scope.fakeDialog.dialog).toHaveBeenCalledWith({dialogFade: false, resolve: {club: jasmine.any(Function)}});
        expect(scope.fakeDialog.club.contact_officer).toEqual('Contact Officer 1');
        expect(scope.fakeDialog.open).toHaveBeenCalledWith('club/club_edit.tpl.html', 'ClubEditCtrl');

        // expect a get after the successful save 
        scope.httpBackend.expect('GET', '../clubs.json').respond([]);
        scope.$digest();        
        scope.httpBackend.flush();
      });
    });
  });

Next, we create tests for the edit controller. We use the mocks from the overall controller test, including the club mock..

  describe( 'Edit controller', function() {
    //mock the controller
    beforeEach(angular.mock.inject(function($controller){

      //declare the controller and inject our parameters
      $controller('ClubEditCtrl', {$scope: scope, dialog: scope.fakeDialog, club: scope.fakeClub});
    }));

    // tests start here
    it('Submit calls put on server', function(){
      // we expect $update to be called on fakeClub, and close to be called on fakeDialog
      spyOn(scope.fakeClub, "$update").andCallThrough();
      spyOn(scope.fakeDialog, "close").andCallThrough();

      scope.submit();

      expect(scope.fakeClub.$update).toHaveBeenCalled();
      expect(scope.fakeDialog.close).toHaveBeenCalled();
    }); 

    it('Cancel does not call put on server', function(){
      // we expect $update not to be called on fakeClub, and close to be called on fakeDialog
      spyOn(scope.fakeClub, "$update").andCallThrough();
      spyOn(scope.fakeDialog, "close").andCallThrough();

      scope.cancel();

      expect(scope.fakeClub.$update).not.toHaveBeenCalled();
      expect(scope.fakeDialog.close).toHaveBeenCalledWith('cancel');
    }); 
  });

});

Since we don’t have much logic, really all we’re testing is that when we call submit, it calls the $update method on our fake club, and then calls close on the dialog.  When we call cancel, it doesn’t call the $update method, but does call close. At a later date, I am aiming to also update this page with logic for end-to-end tests, but for now, this is going to have to do.

You should get a test result that looks similar to:

Tutorial_4.5 test results

The final code for this test is on github: tutorial_4.5.

Onwards and upwards to tutorial number 5, in which we create the new and delete functions, and introduce some error handling.

Advertisements

3 thoughts on “AngularJS and Rails Tutorial: part 4.5 unit testing bootstrap modals with Karma

  1. Pingback: AngularJS and Rails Tutorial: part 4 ngGrid and edit in a modal popup | technpol

  2. Pingback: AngularJS and Rails CRUD application using ng-boilerplate and twitter bootstrap: Tutorial Index | technpol

  3. Pingback: AngularJS and Rails CRUD application using ng-boilerplate and twitter bootstrap | Gatelockservice

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s