The AngularJS' module
ngMockE2E allows to fake HTTP backend implementation for unit testing and to respond with static or dynamic responses via the
when API and its shortcuts (
whenGET,
whenPOST, etc). In this post, I will only demonstrate how to respond to any HTTP calls by mocking the responses. We will also see how the network load can be simulated. Such behavior gives a feeling of real remote calls. The implemented
example is available on Plunker. It shows a small CRUD application to manage imaginary persons. There are four buttons for sending AJAX requests in a REST like manner. When a request is sent, you can see the text
Loading... which disappears after 2 seconds when the response "is arrived". In fact, no request is sent of course. This is just a simulation with the AngularJS' module
ngMockE2E. The network load takes exactly 2 seconds but you can set another delay value if you want
The first button sends a GET request to
/persons to receives all available persons. If you click on this button, you will see three persons received from the imaginary backend.
The second button sends a GET request to
/person/:id to receives one person by its ID. The ID is appended to the URI part
/person/. If you type e.g. 2 and click on this button, you should see the following person
There is also a proper validation. If you type e.g. 4, no person will be found because no person with the ID 4 exists in "the backend".
If you type some wrong ID which is not a numeric value, an error message will be shown as well.
The third button sends a POST request to
/person to create a new person or update existing one. The input fields
name and
birthday are required. You can type a name and birthday for a new person, sends the POST request and see the created person recived from "the backend".
Now, if you sends a GET request to
/persons (the first button), you should see the created person in the list of all persons.
The last button deletes a person by sending the DELETE request to
/person/:id. The deleted person is shown above the buttons.
Click on the first button again to ensure that the person was deleted and doesn't exist more.
Let's show the code. First of all you have to include the file
angular-mocks.js after the file
angular.js in order to overwrite the original
$httpBackend functionality.
<script src="https://code.angularjs.org/1.4.8/angular.js"></script>
<script src="https://code.angularjs.org/1.4.8/angular-mocks.js"></script>
What is
$httpBackend? In AngularJS, we normally use a high level
$http or
$resource service for HTTP calls. These services use for their part a low level service called
$httpBackend. The
$httpBackend has useful methods to create new backend definitions for various request types. There is a method
when(method, url, [data], [headers], [keys]) and many shortcut methods such as
- whenGET(url, [headers], [keys])
- whenHEAD(url, [headers], [keys])
- whenDELETE(url, [headers], [keys])
- whenPOST(url, [data], [headers], [keys])
- whenPUT(url, [data], [headers], [keys])
- ...
The most interesting parameter is the
url. This can be a String like
/persons, a regular expression like
/^\/person\/([0-9]+)$/ for
/person/:id or a function that receives the
url and returns true if the
url matches the current definition. These methods return an object with
respond and
passThrough functions that control how a matched request is handled. The
passThrough is useful when the backend REST API is ready to use and you want to pass mocked request to the real HTTP call. In this example, we only use the
respond. The
respond can take the object to be returned, e.g.
$httpBackend.whenGET('/persons').respond(persons) or a function
function(method, url, data, headers, params) which returns an array containing response status (number), response data (string), response headers (object), and the text for the status (string). The file
app.js demonstrates how to use the
$httpBackend.
(function() {
var app = angular.module('app', ["ngMockE2E"]);
app.run(HttpBackendMocker);
// original list of persons
var persons = [
{id: 1, name: 'Max Mustermann', birthdate: '01.01.1970'},
{id: 2, name: 'Sara Smidth', birthdate: '31.12.1982'},
{id: 3, name: 'James Bond', birthdate: '05.05.1960'}
];
// Reg. expression for /person/:id
var regexPersonId = /^\/person\/([0-9]+)$/;
function HttpBackendMocker($httpBackend) {
// GET /persons
$httpBackend.whenGET('/persons').respond(persons);
// GET /person/:id
$httpBackend.whenGET(regexPersonId).respond(function(method, url) {
var id = url.match(regexPersonId)[1];
var foundPerson = findPerson(id);
return foundPerson ? [200, foundPerson] : [404, 'Person not found'];
});
// POST /person
$httpBackend.whenPOST('/person').respond(function(method, url, data) {
var newPerson = angular.fromJson(data);
// does the person already exist?
var existingPerson = findPerson(newPerson.id);
if (existingPerson) {
// update existing person
angular.extend(existingPerson, newPerson);
return [200, existingPerson];
} else {
// create a new person
newPerson.id = persons.length > 0 ? persons[persons.length - 1].id + 1 : 1;
persons.push(newPerson);
return [200, newPerson];
}
});
// DELETE: /person/:id
$httpBackend.whenDELETE(regexPersonId).respond(function(method, url) {
var id = url.match(regexPersonId)[1];
var foundPerson = findPerson(id);
if (foundPerson) {
persons.splice(foundPerson.id - 1, 1);
// re-set ids
for (var i = 0; i < persons.length; i++) {
persons[i].id = i + 1;
}
}
return foundPerson ? [200, foundPerson] : [404, 'Person not found'];
});
// helper function to find a person by id
function findPerson(id) {
var foundPerson = null;
for (var i = 0; i < persons.length; i++) {
var person = persons[i];
if (person.id == id) {
foundPerson = person;
break;
}
}
return foundPerson;
}
}
})();
Now we need a custom service that encapsulates the
$http service. The service will get the name
DataService. It is placed in the file
service.js.
(function() {
var app = angular.module('app');
app.factory('DataService', DataService);
function DataService($http) {
var service = {
getPersons: getPersons,
getPerson: getPerson,
addPerson: addPerson,
removePerson: removePerson
};
return service;
function getPersons() {
return $http.get('/persons').then(
function(response) {
return response.data;
},
function(error) {
// do something in failure case
}
);
}
function getPerson(id) {
return $http.get('/person/' + id).then(
function(response) {
return response.data;
},
function(error) {
if (error.status && error.status === 404) {
return error.data;
} else {
return "Unexpected request";
}
}
);
}
function addPerson(person) {
return $http.post('/person', person).then(
function(response) {
return response.data;
},
function(error) {
// do something in failure case
}
);
}
function removePerson(id) {
return $http.delete('/person/' + id).then(
function(response) {
return response.data;
},
function(error) {
if (error.status && error.status === 404) {
return error.data;
} else {
return "Unexpected request";
}
}
);
}
}
})();
The service
DataService is invoked by a controller which I named
DataController and placed in the
controller.js.
(function() {
var app = angular.module('app');
app.controller('DataController', DataController);
function DataController($scope, DataService) {
var _self = this;
this.persons = [];
this.personId = null;
this.person = {};
this.message = null;
this.loading = false;
this.getPersons = function() {
init();
DataService.getPersons().then(function(data) {
_self.persons = data;
_self.loading = false;
})
}
this.getPerson = function(id) {
// check required input
if ($scope.form.id4get.$error.required) {
_self.message = "Please add person's id";
return;
}
init();
DataService.getPerson(id).then(function(data) {
if (typeof data === "string") {
// error
_self.message = data;
_self.persons = null;
} else {
_self.persons = [data];
}
_self.loading = false;
})
}
this.addPerson = function(person) {
// check required input
if ($scope.form.name.$error.required) {
_self.message = "Please add person's name";
return;
}
if ($scope.form.birthdate.$error.required) {
_self.message = "Please add person's birthdate";
return;
}
init();
DataService.addPerson(person).then(function(data) {
_self.persons = [data];
_self.loading = false;
})
}
this.removePerson = function(id) {
// check required input
if ($scope.form.id4delete.$error.required) {
_self.message = "Please add person's id";
return;
}
init();
DataService.removePerson(id).then(function(data) {
if (typeof data === "string") {
// error
_self.message = data;
_self.persons = null;
} else {
_self.persons = [data];
}
_self.loading = false;
})
}
// helper function to reset internal state
var init = function() {
_self.persons = [];
_self.message = null;
_self.loading = true;
}
}
})();
Now we can use the controller in the view - the file
index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta content="IE=edge" http-equiv="X-UA-Compatible" />
<script src="https://code.angularjs.org/1.4.8/angular.js"></script>
<script src="https://code.angularjs.org/1.4.8/angular-mocks.js"></script>
</head>
<body ng-app="app">
<div ng-controller="DataController as ctrl" ng-cloak>
<ul style="padding-left:0; list-style:none;">
<li ng-repeat="person in ctrl.persons">
{{person.id}} - {{person.name}} - {{person.birthdate}}
</li>
</ul>
<div style="margin-bottom:15px;" ng-show="ctrl.loading">Loading ...</div>
<div style="color:red; margin-bottom:15px;" ng-show="ctrl.message">{{ctrl.message}}</div>
<form name="form" novalidate>
<button ng-click="ctrl.getPersons()">GET /persons</button>
<p></p>
<button ng-click="ctrl.getPerson(ctrl.personId)">GET /person/:id</button>
<input ng-model="ctrl.personId" name="id4get" required placeholder="id" />
<p></p>
<button ng-click="ctrl.addPerson(ctrl.person)">POST /person</button>
<input ng-model="ctrl.person.name" name="name" required placeholder="name" />
<input ng-model="ctrl.person.birthdate" name="birthdate" required placeholder="birthdate" />
<p></p>
<button ng-click="ctrl.removePerson(ctrl.personId)">DELETE /person/:id</button>
<input ng-model="ctrl.personId" name="id4delete" required placeholder="id" />
</form>
</div>
<script src="app.js"></script>
<script src="config.js"></script>
<script src="service.js"></script>
<script src="controller.js"></script>
</body>
</html>
The current implementation has one shortcoming. The text
Loading ... is not shown at all because the response is delivered very quickly, so that user doesn't see it. It would be nice to delay HTTP calls in order to simulate the network load. For this purpose we can use the
$provide service and register a service decorator for the
$httpBackend. The service decorator acts as proxy. If you look into the source code of the
$httpBackend, you can see that the
$httpBackend is created by the constructor
function(method, url, data, callback, headers, timeout, withCredentials). The four parameter
callback is responsible for the response. We have to provide our own implementation which I named
delayedCallback. The
delayedCallback function invokes the original callback with a delay (here 2 seconds) by means of
setTimeout(). We delegate the proxy call to the
$httpBackend instantiation, but with the new delayed callback. The injected
$delegate object points exactly to the
$httpBackend. The full code is shown below.
(function() {
var app = angular.module('app');
app.config(HttpBackendConfigurator);
function HttpBackendConfigurator($provide) {
$provide.decorator('$httpBackend', HttpBackendDecorator);
function HttpBackendDecorator($delegate) {
var proxy = function(method, url, data, callback, headers, timeout, withCredentials) {
// create proxy for callback parameter
var delayedCallback = function() {
// simulate network load with 2 sec. delay
var delay = 2000;
// Invoke callback with delaying
setTimeout((function() {
callback.apply(this, arguments[0]);
}.bind(this, arguments)), delay);
};
// delegate to the original $httpBackend call, but with the new delayed callback
$delegate(method, url, data, delayedCallback, headers, timeout, withCredentials);
};
// the proxy object should get all properties from the original $httpBackend object
angular.extend(proxy, $delegate);
// return proxy object
return proxy;
}
}
})();