I blogged a while ago about how to build ad-hoc test page for Angular / ASP.NET MVC Application. In this post I will try to establish a pattern for testing Angular controllers using TypeScript and Jasmine. Before you start, you have to use Nuget and bring in jasmine framework. I used version 1.3, as I had troubles getting 2.0 to work with boot.js it includes.
Here is a controller I want to test, written in TypeScript.
module app.contacts { import IUtilities = app.core.services.IUtilities; import IHttp = app.core.services.http.IHttp; import IHttpResult = app.core.services.http.IHttpResult; import IContactType = app.contactTypes.models.IContactType; export interface IContactTypesScope extends app.core.controllers.ICoreScope { contactTypes: IContactType[]; editType: (contactType: IContactType) => void; deleteType: (contactType: IContactType) => void; add: () => void; } export class ContactsTypesController extends app.core.controllers.CoreController { constructor( private utilities: IUtilities, private $scope: IContactTypesScope, $location: ng.ILocationService, contactsTypeService: app.contactTypes.services.IContactsTypeService) { super($scope); var getData = () => { contactsTypeService.getContactTypes((result: IContactType[]) => { $scope.contactTypes = result; }); }; var deleteType = (contactType: app.contactTypes.models.IContactType) => { contactsTypeService.deleteContactType(contactType, (result: boolean) => { if (result) { getData(); } }); }; getData(); $scope.editType = (contactType: app.contactTypes.models.IContactType) => { $location.path("/contacttype/edit/" + contactType.ContactTypeId); }; $scope.add = () => { $location.path("/contacttype/edit/add"); }; $scope.deleteType = (contactType: app.contactTypes.models.IContactType) => { var buttons: app.core.services.IButtonForMessage[] = [ { mehtod: () => { deleteType(contactType); }, label: "Delete" }, { label: "Cancel" } ]; utilities.showMessage("Delete type " + contactType.Name + "?", false, buttons); }; } } angular.module("app.contacts.contactsTypesController", ["app.core.services.utilities", "app.core.services.http", "app.contactTypes.services"]) .controller("app.contacts.contactsTypesController", ["utilities", "$scope", "$location", "contactsTypeService", function ( utilities: IUtilities, $scope: IContactTypesScope, $location: ng.ILocationService, contactsTypeService: app.contactTypes.services.IContactsTypeService) { return new ContactsTypesController(utilities, $scope, $location, contactsTypeService); }]); }
You will notice a couple of things. First of all, I am adding “export” keyword to my scope and controller. This will ensure that I can access those classes outside of the module they are in. Also, you see that my delete method pops up confirmation dialog through my utilities.showMessage method. This message is just using Bootstrap dialog window with yes/no buttons. Now, let’s write a test.
When you write tests, you have to decide what you want to mock. In order to have my tests run fast, I want to mock HTTP communications, and that is all. Doing so with Angular is very easy, just bring in angular-mocks.js script.
<script src="~/Scripts/angular-mocks.js"></script>
This script will introduce a mock for the http back end service (ng.IHttpBackendService). With this mock service, you can inject expectation, such as :
backEnd = $httpBackend; backEnd.when("GET", globalsService.webApiBaseUrl + "/ContactTypes/Get").respond( { "Success": true, "ErrorMessage": "", "Result": [ { "ContactTypeId": 2, "Name": "Coworker" }, { "ContactTypeId": 3, "Name": "Family" }, { "ContactTypeId": 5, "Name": "Fresh" }, { "ContactTypeId": 1, "Name": "Friend" } ] });
What this does is mocks a specific HTTP call and the response to it. Later, you need to verify that all expectations have been met by calling:
backEnd.flush(); backEnd.verifyNoOutstandingExpectation(); backEnd.verifyNoOutstandingRequest();
At the same time we will verify that no outstanding requests are pending. You need to flush pending requests first of course. Of course, as with any other tests, we have to do some setup operation. Those can be done with beforeEach() method in jasmine. We will also need to teach angular mocks which module to look in for the dependencies. Finally, we need to call inject() method in angular mocks script to get the dependencies.
describe("Contact Types Controller", function () { var controller: app.contacts.ContactsTypesController; var scope: app.contacts.IContactTypesScope; var utilities: app.core.services.IUtilities; var $location: ng.ILocationService; var contactsTypeService: app.contactTypes.services.IContactsTypeService; var backEnd: ng.IHttpBackendService; beforeEach(function () { angular.mock.module("app.core.services.utilities"); angular.mock.module("app.core.services.http"); angular.mock.module("app.globalsModule"); angular.mock.module("app.contactTypes.services"); }); beforeEach(inject(function ( $rootScope: ng.IRootScopeService, _$location_: ng.ILocationService, _utilities_: app.core.services.IUtilities, _contactsTypeService_: app.contactTypes.services.IContactsTypeService, $httpBackend: ng.IHttpBackendService, globalsService: interfaces.IGLobals) { scope = <any>$rootScope.$new(); $location = _$location_; utilities = _utilities_; contactsTypeService = _contactsTypeService_; backEnd = $httpBackend; backEnd.when("GET", globalsService.webApiBaseUrl + "/ContactTypes/Get").respond( { "Success": true, "ErrorMessage": "", "Result": [ { "ContactTypeId": 2, "Name": "Coworker" }, { "ContactTypeId": 3, "Name": "Family" }, { "ContactTypeId": 5, "Name": "Fresh" }, { "ContactTypeId": 1, "Name": "Friend" } ] }); backEnd.when("POST", globalsService.webApiBaseUrl + "/ContactTypes/Delete", { "ContactTypeId": 2, "Name": "Coworker" }).respond( { "Success": true, "ErrorMessage": "", "Result": { "ContactTypeId": 2, "Name": "Coworker" } }); }));
Describe method is used to declare a category of tests, in my case a single controller. I also declare my private variables right inside the body of the test. After that I have two beforeEach calls. In first one I bring in all modules I depend on for this controller I am testing – ContactTypesController. In the second beforeEach I populate my private variables that hold dependencies through dependency injection of Angular mocking framework. At the same time, I am declaring the expected calls to HTTP, in my case two calls – to get the list of contact types and to delete a contract type.
Next, I have to decide what I want to test. I follow my basic testing rules – I only test public interfaces, in my case methods that are on $scope object. Actually, this brings up another point. I follow the following philosophy in Angular controllers. All private methods I leave on controller, and I only put methods and properties on $scope that need to be bound to UI. This keeps my code cleanly separated and minimizes the number of variables on $scope, which carry inherent overhead in Angular. Once you go over a few hundreds of bindings, you will start noticing performance drawbacks. Angular authors state that you must stay under 2000 bindings per active view. Let’s write a simple test – I want to make sure I can create a controller instance with all the dependencies.
it("should create controller", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); expect(controller).not.toBeNull(); expect(typeof scope.editType).toBe("function"); expect(typeof scope.deleteType).toBe("function"); });
“It” method of Jasmine is the main test method, corresponding to {TestMethod] attribute in .NET MsTest. “Describe” method corresponds to {TestClass] attribute. What we do in the test above, is simply call controller’s constructor and very that the controller is not null and that it has the initial data. The reason I expect it to be populated is because in my constructor I call getData on my service to get the initial list of contact types. Of course, I do not make the actual call to Web Api, my request stops at fake http backend in Angular and returns the expectation data I setup prior, using backEnd.When() method. Finally, I make sure all public methods got created. Other tests pretty much follow the same flow:
it("should have data", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); backEnd.flush(); expect(scope.contactTypes).toBeDefined(); expect(scope.contactTypes).not.toBeNull(); }); it("should have correct number of rows", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); backEnd.flush(); expect(scope.contactTypes.length).toBe(4); }); it("should change location on edit", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); backEnd.flush(); expect(scope.contactTypes.length).toBe(4); scope.editType(scope.contactTypes[0]); expect($location.path()).toBe("/contacttype/edit/" + scope.contactTypes[0].ContactTypeId); }); it("should change location on add", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); backEnd.flush(); expect(scope.contactTypes.length).toBe(4); scope.add(); expect($location.path()).toBe("/contacttype/edit/add"); });
Finally, we are down to delete test. This test is trickier because I have to deal with confirmation dialog. JQuery to the rescue there. I just have to click on the confirmation button.
it("should delete type", () => {
controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService);
backEnd.flush();
expect(scope.contactTypes.length).toBe(4);
scope.deleteType(scope.contactTypes[0]);
var deleteButton = $("#globalMessageDialog button").first();
deleteButton.trigger("click");
backEnd.flush();
backEnd.verifyNoOutstandingExpectation();
backEnd.verifyNoOutstandingRequest();
});
Here is the code for entire spec file.
/// <reference path="../../../scripts/typings/angularjs/angular.d.ts" /> /// <reference path="../../../app/home/interfaces.ts" /> /// <reference path="../../../scripts/typings/angularjs/angular-mocks.d.ts" /> describe("Contact Types Controller", function () { var controller: app.contacts.ContactsTypesController; var scope: app.contacts.IContactTypesScope; var utilities: app.core.services.IUtilities; var $location: ng.ILocationService; var contactsTypeService: app.contactTypes.services.IContactsTypeService; var backEnd: ng.IHttpBackendService; beforeEach(function () { angular.mock.module("app.core.services.utilities"); angular.mock.module("app.core.services.http"); angular.mock.module("app.globalsModule"); angular.mock.module("app.contactTypes.services"); }); beforeEach(inject(function ( $rootScope: ng.IRootScopeService, _$location_: ng.ILocationService, _utilities_: app.core.services.IUtilities, _contactsTypeService_: app.contactTypes.services.IContactsTypeService, $httpBackend: ng.IHttpBackendService, globalsService: interfaces.IGLobals) { scope = <any>$rootScope.$new(); $location = _$location_; utilities = _utilities_; contactsTypeService = _contactsTypeService_; backEnd = $httpBackend; backEnd.when("GET", globalsService.webApiBaseUrl + "/ContactTypes/Get").respond( { "Success": true, "ErrorMessage": "", "Result": [ { "ContactTypeId": 2, "Name": "Coworker" }, { "ContactTypeId": 3, "Name": "Family" }, { "ContactTypeId": 5, "Name": "Fresh" }, { "ContactTypeId": 1, "Name": "Friend" } ] }); backEnd.when("POST", globalsService.webApiBaseUrl + "/ContactTypes/Delete", { "ContactTypeId": 2, "Name": "Coworker" }).respond( { "Success": true, "ErrorMessage": "", "Result": { "ContactTypeId": 2, "Name": "Coworker" } }); })); it("should create controller", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); expect(controller).not.toBeNull(); expect(typeof scope.editType).toBe("function"); expect(typeof scope.deleteType).toBe("function"); }); it("should have data", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); backEnd.flush(); expect(scope.contactTypes).toBeDefined(); expect(scope.contactTypes).not.toBeNull(); }); it("should have correct number of rows", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); backEnd.flush(); expect(scope.contactTypes.length).toBe(4); }); it("should change location on edit", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); backEnd.flush(); expect(scope.contactTypes.length).toBe(4); scope.editType(scope.contactTypes[0]); expect($location.path()).toBe("/contacttype/edit/" + scope.contactTypes[0].ContactTypeId); }); it("should change location on add", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); backEnd.flush(); expect(scope.contactTypes.length).toBe(4); scope.add(); expect($location.path()).toBe("/contacttype/edit/add"); }); it("should delete type", () => { controller = new app.contacts.ContactsTypesController(utilities, scope, $location, contactsTypeService); backEnd.flush(); expect(scope.contactTypes.length).toBe(4); scope.deleteType(scope.contactTypes[0]); var deleteButton = $("#globalMessageDialog button").first(); deleteButton.trigger("click"); backEnd.flush(); backEnd.verifyNoOutstandingExpectation(); backEnd.verifyNoOutstandingRequest(); }); });
Thanks.
Enjoy.
Very good article that explains how we can implement unit testing in angularjs with typescript. I was looking for such article from a couple of days and this is the best article to give you a headstart.
This is a very useful article. Thanks you!
I wanted to actually run the tests. To do so, I will need all referenced code:
app.contactTypes.services,
app.contactTypes.models
app.core.controllers,
app.core.services,
etc.
Where can I find the missing code?
Also, do you know about a good tutorial about unit testing Angular code that is written in TypeScript?
Thanks again for the valuable information!
interfaces
@Dimitre
Unfortunately, I no longer have this project around…