Testing services involves mocking out the boundary of the system, which in most cases will be a web server (which is normally accessed over HTTP).

We want to test that our services:

  1. Pass any request parameters and/or data to the web server properly
  2. Take a web server response and parse it / build the response models correctly.

Since it will be the focus of most applications, this guide will center around $httpBackend in ng-mock.

Note that all examples assume Jasmine 2.x.

First HeroesService Test

Let's start with a test of HeroesService.loadHeroes(), and then we'll explain it.

Recall that our HeroesService from the services guide looks like this:

angular.module( 'heroes' ).factory( 'HeroesService', function( $http, HeroesReader, HeroesWriter ) {
    'use strict';

    // Public API
    return {
        loadHeroes : loadHeroes,
        loadHero   : loadHero,
        saveHero   : saveHero
    };


    /**
     * Loads the list of heroes.
     * 
     * @return {Promise} A promise which is resolved with an array of 
     *   {@link Hero} objects, or is rejected if an error occurs.
     */
    function loadHeroes() {
        return $http( { url: '/path/to/heroes' } ).then(
            function onSuccess( response ) {
               return HeroesReader.readHeroes( response.data );
            }
        );
    }


    /**
     * Loads a Hero by ID.
     *
     * @param {Number} heroId The ID of the hero to load.
     * @return {Promise} A promise which is resolved with a {@link Hero} object
     *   when successful, or is rejected if an error occurs.
     */
    function loadHero( heroId ) {
        return $http( { url: '/path/to/hero/' + heroId } ).then( 
            function onSuccess( response ) {
                return HeroesReader.readHero( response.data );
            }
        } );
    }


    /**
     * Saves a Hero to the server.
     *
     * @param {Hero} hero The Hero to save.
     * @return {Promise} A promise which is resolved with an updated {@link Hero}
     *   when the operation is complete, or is rejected if an error occurs.
     */
    function saveHero( hero ) {
        var data = HeroWriter.write( hero );

        return $http( { 
            url    : '/path/to/hero' + ( !hero.id ? '' : '/' + hero.id ),
            method : !hero.id ? 'POST' : 'PUT',
            data   : HeroesWriter.writeHero( hero )
        } ).then( function( response ) {
            return HeroesReader.readHero( response.data );
        } );
    }

} );

And our first test of the loadHeroes() method:

describe( 'HeroesService', function() {
    'use strict';

    var $httpBackend,
        Hero,
        HeroesService,
        HeroesTestData,

        successFn,  // to observe promise resolution results
        errorFn;    // to observe promise rejection results

    beforeEach( module( 'heroes' ) );  // load the module for the test

    beforeEach( inject( function( $injector ) {
        $httpBackend   = $injector.get( '$httpBackend' );
        Hero           = $injector.get( 'Hero' );
        HeroesService  = $injector.get( 'HeroesService' );
        HeroesTestData = $injector.get( 'HeroesTestData' );

        successFn      = jasmine.createSpy( 'successFn' );
        errorFn        = jasmine.createSpy( 'errorFn' );
    } ) );


    /**
     * Helper function to check the state of a Hero instance.
     */
    function expectHero( hero ) {
        expect( hero ).toEqual( jasmine.any( Hero ) );  // instanceof check

        return {
            toContain : function( id, name, lastBattle ) {
                expect( hero.id ).toBe( id );
                expect( hero.name ).toBe( name );
                expect( hero.lastBattle.format() ).toBe( lastBattle.format() );
            }
        };
    }


    describe( 'loadHeroes()', function() {

        it( 'should return a promise that is resolved with an ' +
            'array of Hero objects'
        function() {
            $httpBackend.expectGET( '/path/to/heroes' )
                .respond( 200, HeroesTestData.heroes );

            HeroesService.loadHeroes().then( successFn );
            expect( successFn ).not.toHaveBeenCalled();  // not yet

            $httpBackend.flush();
            expect( successFn ).toHaveBeenCalledWith( jasmine.any( Array ) );

            var heroes = successFn.calls.argsFor( 0 )[ 0 ];
            expect( heroes.length ).toBe( 3 );
            expectHero( heroes[ 0 ] )
                .toContain( 11, 'Mr. Nice', moment( '2016-05-10T18:00:52' ) );
            expectHero( heroes[ 1 ] )
                .toContain( 12, 'Narco',    moment( '2016-02-01T08:52:27' ) );
            expectHero( heroes[ 2 ] )
                .toContain( 13, 'Bombasto', moment( '2016-05-10T18:00:52' ) );
        } );

    } );

} );

Let's go over the test piece by piece:

  1. $httpBackend.expectGET( '/path/to/heroes' ).respond( 200, HeroesTestData.heroesResponse );
    Expects that an HTTP GET request will be made to the '/path/to/heroes' URL, and also programs in the response data.

  2. HeroesService.loadHeroes().then( successFn );
    Calls the method under test, providing our successFn spy function which will be called when the HTTP request is complete.

  3. expect( successFn ).not.toHaveBeenCalled(); Simply checks that successFn hasn't been called yet (it won't be called until the HTTP request is complete, and the promise resolves).

  4. $httpBackend.flush();
    "flush" the call from the backend (i.e. cause the HTTP request made by HeroesService.loadHeroes() to return its data, and therefore resolve the promise)

  5. expect( successFn ).toHaveBeenCalledWith( jasmine.any( Array ) );
    Expects that the promise returned by HeroesService.loadHeroes() has resolved, with an array as its argument (our array of Hero objects).

  6. var heroes = successFn.calls.argsFor( 0 )[ 0 ];
    Retrieves the first call to the successFn spy, and the first argument that it was called with. This is our array of Heroes from the promise resolution.

  7. expectHero( heroes[ 0 ] ).toContain( ... )
    This is a call to our helper function to assert the correct state of each Hero instance.

Note about testing the returned models: The above example covers checking each of the returned models from the response. However, you may choose to only check a subset of the returned models for large collections. Generally, checking just one or two models is enough to confirm correct behavior.

For more information on $httpBackend, see: https://docs.angularjs.org/api/ngMock/service/$httpBackend

HeroesTestData

HeroesTestData (used above) is our library of known response objects that our server can provide to us for heroes, which can be used in many tests. Example:

angular.module( 'heroes' ).factory( 'HeroesTestData', function() {

    var mrNice   = { id: 11, name: 'Mr. Nice', lastBattle: '2016-04-22T14:22:01' },
        narco    = { id: 12, name: 'Narco',    lastBattle: '2016-02-01T08:52:27' },
        bombasto = { id: 13, name: 'Bombasto', lastBattle: '2016-05-10T18:00:52' };

    return {
        mrNice   : mrNice,
        narco    : narco,
        bombasto : bombasto,

        heroes   : [ mrNice, narco, bombasto ]
    };

} );

This test data will be used not only for the HeroesService tests, but also for the hero page tests as well.

Continuing the Example

Let's add tests for the remaining methods of HeroesService:

describe( 'HeroesService', function() {
    'use strict';

    var $httpBackend,
        Hero,
        HeroesService,
        HeroesTestData,
        HeroesWriter,

        successFn,  // to observe promise resolution results
        errorFn;    // to observe promise rejection results

    beforeEach( module( 'heroes' ) );  // load the module for the test

    beforeEach( inject( function( $injector ) {
        $httpBackend   = $injector.get( '$httpBackend' );
        Hero           = $injector.get( 'Hero' );
        HeroesService  = $injector.get( 'HeroesService' );
        HeroesTestData = $injector.get( 'HeroesTestData' );
        HeroesWriter   = $injector.get( 'HeroesWriter' );

        successFn      = jasmine.createSpy( 'successFn' );
        errorFn        = jasmine.createSpy( 'errorFn' );
    } ) );


    /**
     * Helper function to check the state of a Hero instance.
     */
    function expectHero( hero ) {
        expect( hero ).toEqual( jasmine.any( Hero ) );  // instanceof check

        return {
            toContain : function( id, name, lastBattle ) {
                expect( hero.id ).toBe( id );
                expect( hero.name ).toBe( name );
                expect( hero.lastBattle.format() ).toBe( lastBattle.format() );
            }
        };
    }


    describe( 'loadHeroes()', function() {

        it( 'should return a promise that is resolved with an ' +
            'array of Hero objects'
        function() {
            $httpBackend.expectGET( '/path/to/heroes' )
                .respond( 200, HeroesTestData.heroes );

            HeroesService.loadHeroes().then( successFn );
            expect( successFn ).not.toHaveBeenCalled();  // not yet

            $httpBackend.flush();
            expect( successFn ).toHaveBeenCalledWith( jasmine.any( Array ) );

            var heroes = successFn.calls.argsFor( 0 )[ 0 ];
            expect( heroes.length ).toBe( 3 );
            expectHero( heroes[ 0 ] )
                .toContain( 11, 'Mr. Nice', moment( '2016-05-10T18:00:52' ) );
            expectHero( heroes[ 1 ] )
                .toContain( 12, 'Narco',    moment( '2016-02-01T08:52:27' ) );
            expectHero( heroes[ 2 ] )
                .toContain( 13, 'Bombasto', moment( '2016-05-10T18:00:52' ) );
        } );


        it( 'should return a promise that should reject if an error occurs', function() {
            $httpBackend.expectGET( '/path/to/heroes' ).respond( 500 );

            HeroesService.loadHeroes().catch( errorFn );
            expect( errorFn ).not.toHaveBeenCalled();  // not yet

            $httpBackend.flush();
            expect( errorFn ).toHaveBeenCalled();
        } );

    } );


    describe( 'loadHero()', function() {

        it( 'should return a promise which is resolved when the Hero has been ' +
            'loaded successfully',
        function() {
            $httpBackend.expectGET( '/path/to/hero/11' )
                .respond( 200, HeroesTestData.mrNice );

            HeroesService.loadHero( 11 ).then( successFn );
            expect( successFn ).not.toHaveBeenCalled();  // not yet

            $httpBackend.flush();
            expect( successFn ).toHaveBeenCalledWith( jasmine.any( Hero ) );

            var hero = successFn.calls.argsFor( 0 )[ 0 ];
            expectHero( hero )
                .toContain( 11, 'Mr. Nice', moment( '2016-05-10T18:00:52' ) );
        } );


        it( 'should return a promise that should reject if an error occurs', function() {
            $httpBackend.expectGET( '/path/to/hero/11' ).respond( 500 );

            HeroesService.loadHero( 11 ).catch( errorFn );
            expect( errorFn ).not.toHaveBeenCalled();  // not yet

            $httpBackend.flush();
            expect( errorFn ).toHaveBeenCalled();
        } );

    } );


    describe( 'saveHero()', function() {

        it( 'should perform a POST request for a new Hero, and resolve when ' +
            'complete', 
        function() {
            var lastBattle = moment(),
                newId = 42;

            var newHero = new Hero( { 
                name: 'Mr. Mean', 
                lastBattle: lastBattle 
            } );
            var serializedHero = HeroesWriter.writeHero( newHero );

            $httpBackend.expectPOST( '/path/to/hero', serializedHero )
                .respond( 200, _.merge( { id: newId }, serializedHero } } );

            HeroesService.saveHero( newHero ).then( successFn );
            expect( successFn ).not.toHaveBeenCalled();  // not yet

            $httpBackend.flush();
            expect( successFn ).toHaveBeenCalledWith( jasmine.any( Hero ) );

            var newHeroFromServer = successFn.calls.argsFor( 0 )[ 0 ];
            expectHero( newHeroFromServer )
                .toContain( newId, 'Mr. Mean', lastBattle );
        } );


        it( 'should perform a PUT request for an existing Hero, and resolve when ' +
            'complete',
        function() {
            var lastBattle = moment();
            var updatedHero = new Hero( { 
                id: 11, 
                name: 'Mr. Mean', 
                lastBattle: lastBattle 
            } );
            var serializedHero = HeroesWriter.writeHero( updatedHero );

            $httpBackend.expectPUT( '/path/to/hero/11', serializedHero )
                .respond( 200, serializedHero );

            HeroesService.saveHero( updatedHero ).then( successFn );
            expect( successFn ).not.toHaveBeenCalled();  // not yet

            $httpBackend.flush();
            expect( successFn ).toHaveBeenCalledWith( jasmine.any( Hero ) );

            var updatedHeroFromServer = successFn.calls.argsFor( 0 )[ 0 ];
            expectHero( updatedHeroFromServer )
                .toContain( 11, 'Mr. Mean', lastBattle );
        } );


        it( 'should reject its promise if an error occurs', function() {
            var newHero = new Hero( {
                name: 'Mr. Mean', 
                lastBattle: moment() 
            } );
            var serializedHero = HeroesWriter.writeHero( newHero );

            $httpBackend.expectPOST( '/path/to/hero', serializedHero )
                .respond( 500 );

            HeroesService.saveHero( newHero ).catch( errorFn );
            expect( errorFn ).not.toHaveBeenCalled();  // not yet

            $httpBackend.flush();
            expect( errorFn ).toHaveBeenCalled();
        } );

    } );

} );

Next Article: Testing Components