Mocking http responses in Angular JS controllers with Jasmine

Hackered
Sunday, September 7, 2014
by Sean McAlinden

For a guide to setting up your test environment using Jasmine & Karma, please take a look at Test driving JavaScript with Jasmine, Grunt, Karma, PhantomJS and Node js. This post is all about unit testing AngularJS controllers which perform http requests.

The Acceptance Criteria

When I pass a null or undefined value to GetUser Then an error message should be populated stating "˜User id must be present' When I pass a non numeric value to GetUser Then an error message should be populated stating "˜Invalid user id' When I pass an existing user id to GetUser Then the users first name should be populated When I pass an non-existing user id to GetUser Then an error message should be populated stating "˜User not found'

Let Karma know about AngularJS

Assuming you are setting up similar to Test driving JavaScript with Jasmine, Grunt, Karma, PhantomJS and Node js you wil need to let Karma know about AngularJS and its mocking library. My Karma config file looks like the following, play special attention to the files property: Note: Ensure you put the references in the files property in the correct order, otherwise you will get interesting reference errors.

module.exports = function (config) {
    config.set({

        // base path, that will be used to resolve files and exclude
        basePath: '',

        // frameworks to use
        frameworks: ['jasmine'],

        // list of files / patterns to load in the browser
        files: [
            'Scripts/angular.js',
            'Scripts/angular-mocks.js',
            'js/**/*.js'
        ],

        // list of files to exclude
        exclude: [
        ],

        // test results reporter to use
        reporters: ['progress'],

        // web server port
        port: 9876,

        // enable / disable colors in the output (reporters and logs)
        colors: true,

        // level of logging
        logLevel: config.LOG_INFO,

        // enable / disable watching file and executing tests whenever any file changes
        autoWatch: true,

        // Start these browsers
        browsers: ['PhantomJS'],

        // If browser does not capture in given timeout [ms], kill it
        captureTimeout: 60000,

        // Continuous Integration mode
        // if true, it capture browsers, run tests and exit
        singleRun: false
    });
};

Test Setup

The first thing we should do is set up our test feature. Other than the overall describe function which is part of Jasmine, the rest of the code is setting up our controller, it's scope and a mock $http object.

describe('userController tests', function () {
    // Load the module
    beforeEach(module('myApp'));

    var scope, httpMock;

    // inject the $controller, $rootScope and $injector services
    beforeEach(inject(function ($controller, $rootScope, $injector) {

        // Setup mock object for $http
        httpMock = $injector.get('$httpBackend');

        // Create a new scope that's a child of the $rootScope
        scope = $rootScope.$new();

        // Create the controller
         $controller('userController', {
            $scope: scope
        });
    }));   
})

Lets take a look at what is going on.

  1. Before each test we are loading our module "˜myApp' (this is what we are going to call our app).
  2. Setup up some root level variables for accessing scope and our http mock.
  3. Before each test inject the controller, rootscope and injector services.
  4. Populate the httpMock using the injector service (referencing the built in Angular JS $httpBackend "“ the $http mock).
  5. Create a new child scope for the controller.
  6. Create the controller with the newly created child scope.

Criteria 1

When I pass a null or undefined value to GetUser Then an error message should be populated stating "˜User id must be present'Create a test that meets this requirement using the following code and place it after the final beforeEach method call:

    
it('should show error when user id is missing',
    function () {
        var errorMessage = 'User id must be present';
        scope.getUser(undefined);
        expect(scope.errorMessage).toBe(errorMessage);
    });

 

Create the code

Lets create just enough code to pass this criteria

angular.module('myApp', [])
.controller('userController', function ($scope) {
    $scope.getUser = function (userId) {
        if (!userId) {
            $scope.errorMessage = 'User id must be present';
            return;
        }
    }
})

Criteria 2

When I pass a non numeric value to GetUser Then an error message should be populated stating "˜Invalid user id'Create a test that meets this requirement using the following code and place it after the previous test:

it('should show error when user id is not numeric',
    function () {
        var errorMessage = 'Invalid user id';
        scope.getUser('some string');
        expect(scope.errorMessage).toBe(errorMessage);
    });

Create the code

Lets add a check to ensure the user id is a number, your controller should look like the following:

angular.module('myApp', [])
.controller('userController', function ($scope) {
    $scope.getUser = function (userId) {
        if (!userId) {
            $scope.errorMessage = 'User id must be present';
            return;
        }

        if (isNaN(userId)) {
            $scope.errorMessage = 'Invalid user id';
            return;
        }
    }
})

Criteria 3

When I pass an existing user id to GetUser Then the users first name should be populatedAdd the following variable in the test class where the previous variables were declared (we are going to re-use it):

var userId = 1;

  Create a test that meets this requirement using the following code and place it after the previous test:

it ('should set users firstname on scope',
    function () {
        var firstName = 'Sean';
        httpMock.expectGET('/users/' + userId).respond(200, { userId: userId, firstName: firstName });
        scope.getUser(userId);
        httpMock.flush();
        expect(scope.firstName).toBe(firstName);
    });

As you can see, we are using the httpMock we created earlier to setup an expectation for a given request. The important thing to note is is the httpMock.flush();method call, this needs to be called to get around the general asynchronous nature of AngularJS http calls.

Create the code

Create the call to the users endpoint and on success set the first name scope property, your code should look like the following:

angular.module('myApp', [])
.controller('userController', function ($scope) {
    $scope.getUser = function (userId) {
        if (!userId) {
            $scope.errorMessage = 'User id must be present';
            return;
        }

        if (isNaN(userId)) {
            $scope.errorMessage = 'Invalid user id';
            return;
        }

        $http.get('/users/' + userId)
            .success(function(data, status, headers) {
                $scope.firstName = data.firstName;
            });
    }
})

Criteria 4

When I pass an non-existing user id to GetUser Then an error message should be populated stating "˜User not found'Lets test the scenario where the user is not found. Create a test that meets this requirement using the following code and place it after the previous test:

it('should show error when get user fails',
    function () {
        var errorMessage = 'User not found';
        httpMock.expectGET('/users/' + userId).respond(404, { errorMessage: errorMessage });
        scope.getUser(userId);
        httpMock.flush();
        expect(scope.errorMessage).toBe(errorMessage);
    });

As you can see, this is very similar to the previous test, however this time we are returning a 404 with an error message.

Create the code

Your final code should look like the following:

angular.module('myApp', [])
.controller('userController', function ($scope, $http) {
    $scope.getUser = function (userId) {
        if (!userId) {
            $scope.errorMessage = 'User id must be present';
            return;
        }

        if (isNaN(userId)) {
            $scope.errorMessage = 'Invalid user id';
            return;
        }

        $http.get('/users/' + userId)
        .success(function (data, status, headers) {
            $scope.firstName = data.firstName;
        })
        .error(function (err) {
            $scope.errorMessage = err.errorMessage;
        });
    }
})

Full Test Class

describe('userController tests', function () {
    // Load the module
    beforeEach(module('myApp'));

    var scope, httpMock;
    var userId = 1;

    // inject the $controller, $rootScope and $injector services
    beforeEach(inject(function ($controller, $rootScope, $injector) {

        // Setup mock object for $http
        httpMock = $injector.get('$httpBackend');

        // Create a new scope that's a child of the $rootScope
        scope = $rootScope.$new();

        // Create the controller
         $controller('userController', {
            $scope: scope
        });
    }));

    it('should show error when user id is missing',
        function () {
            var errorMessage = 'User id must be present';
            scope.getUser(undefined);
            expect(scope.errorMessage).toBe(errorMessage);
        });

    it('should show error when user id is not numeric',
        function () {
            var errorMessage = 'Invalid user id';
            scope.getUser('some string');
            expect(scope.errorMessage).toBe(errorMessage);
        });

    it ('should set users firstname on scope',
        function () {
            var firstName = 'Sean';
            httpMock.expectGET('/users/' + userId).respond(200, { userId: userId, firstName: firstName });
            scope.getUser(userId);
            httpMock.flush();
            expect(scope.firstName).toBe(firstName);
        });

    it('should show error when get user fails',
       function () {
           var errorMessage = 'User not found';
           httpMock.expectGET('/users/' + userId).respond(404, { errorMessage: errorMessage });
           scope.getUser(userId);
           httpMock.flush();
           expect(scope.errorMessage).toBe(errorMessage);
       });
})

Summary

There you have it, really nice and straight forward way to mock http requests in AngularJS.