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:
- Pass any request parameters and/or data to the web server properly
- 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:
$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.HeroesService.loadHeroes().then( successFn );
Calls the method under test, providing oursuccessFnspy function which will be called when the HTTP request is complete.expect( successFn ).not.toHaveBeenCalled();Simply checks thatsuccessFnhasn't been called yet (it won't be called until the HTTP request is complete, and the promise resolves).$httpBackend.flush();
"flush" the call from the backend (i.e. cause the HTTP request made byHeroesService.loadHeroes()to return its data, and therefore resolve the promise)expect( successFn ).toHaveBeenCalledWith( jasmine.any( Array ) );
Expects that the promise returned byHeroesService.loadHeroes()has resolved, with an array as its argument (our array ofHeroobjects).var heroes = successFn.calls.argsFor( 0 )[ 0 ];
Retrieves the first call to thesuccessFnspy, and the first argument that it was called with. This is our array of Heroes from the promise resolution.expectHero( heroes[ 0 ] ).toContain( ... )
This is a call to our helper function to assert the correct state of eachHeroinstance.
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