Practical guide for enterprise unit testing in Angular 1.X

Practical guide for enterprise unit testing in Angular 1.X

I’ve written this documentation for a project that I worked at. This project was implemented by very geographically diverse team and served millions of visits by a LOT of paying clients. We needed this product to be reliable and robust and for that we needed good tests.

I believe that I don’t need to introduce Angular 1.x. It is a solid, well tested and well maintained JS framework that a lot of enterprise software is using to support single page applications. Most of the organizations use karma in order to run tests for Angular applications.

This guide will be relevant to anyone that need to maintain angular 1.X application and using karma to do it.

Testing the node.js\Java\PHP backend is not very relevant to this guide. The most difficulties that we had was about testing the front end. Anyone can write tests and there are a lot of hello world examples out there, but the actual problem is writing unit testing that will be maintainable?—?it means that the tests will be:

1. Very simple to write?—?if we will have a lot of overhead functions to write before actually writing the tests, the programmers in the team will not write it.

2. Scalable?—?it means that it will be easy to copy one test as template and use it for all template.

3. Test the right things?—?not testing unnecessary things or things that are not in our responsibility.

In this guide I will show you how we do it?—?from setting up the testing environment, the CI cycle and drilling down to writing tests and analyses the effectivity of the tests. The testing described here are not integration testing but unit testing. I trust you to know the difference.

Setup

For testing we need two things: test runner and testing framework

Test runner

Test runner is some software that allow us to take the front end JavaScript and run it in browser. The browser can be headless browser like phantom or real browser. Krama js is the test runner that we chose for the job. Karma is one of the most popular test runner and it is already wrapped up with a lot of boilerplates. It is compatible with all the javascript task runner out there.

What karma is doing is to load all the javascript files in the project, including the 3rd party javascript files and the HTML. We can attach preprocessors to the file. For example, HTML files will be rendered with ng-html2js (one of Karma plugins) that emulates the rendering of HTML by angular. The JavaScript load into the memory of some browser. It can be phantom?—?headless browser that does not have graphic interface and it is very fast or any other browser.

Installing karma is like any other node.js module. Basically, every boilerplate in angular have the recommended karma configuration along with the package.json that include karma, karma plugin (the preprocessors and the reporter) and sample configuration file.

The configuration file of karma is basically the same in every task runner and it looks like that

module.exports = function(config) {
 config.set({
 // base path, that will be used to resolve files and exclude
 basePath: ‘’,
frameworks: [‘jasmine’],
 
 // list of files / patterns to load in the browser
 files: [
 ‘client/bower_components/jquery/dist/jquery.js’,
 ‘client/bower_components/angular/angular.js’,
 ‘client/bower_components/angular-mocks/angular-mocks.js’,
 ‘client/bower_components/angular-resource/angular-resource.js’,
 ‘client/bower_components/angular-cookies/angular-cookies.js’,
 ‘client/bower_components/angular-sanitize/angular-sanitize.js’,
 ‘client/bower_components/lodash/lodash.js’,
 ‘client/bower_components/angular-ui-router/release/angular-ui-router.js’,
 ‘client/bower_components/angular-bootstrap/ui-bootstrap-tpls.js’,
 ‘client/bower_components/angular-ajax-indicator/dist/angular-ajax-indicator.min.js’,
 ‘client/bower_components/ng-file-upload/ng-file-upload-all.min.js’,
 ‘client/bower_components/angular-toastr/dist/angular-toastr.tpls.js’,
 ‘client/bower_components/ng-file-upload/ng-file-upload.min.js’,
 ‘client/bower_components/angulartics/src/angulartics.js’,
 ‘client/bower_components/angulartics-google-analytics/lib/angulartics-google-analytics.js’,
 ‘client/app/common/prototype-functions.js’,
 ‘client/app/app.js’,
 ‘client/app/**/*.js’,
 ‘client/app/**/*.html’
 ],
 
 preprocessors: {
 ‘client/app/**/*.html’: [‘ng-html2js’]
 },

 
 // list of files / patterns to exclude
 exclude: [
 ],
 
 // coverage reporter generates the coverage
 reporters:[‘spec’],
 
 // web server port
 port: 8080,
 
 logLevel: config.LOG_INFO
 });
 };

More information on this can be found here: https://karma-runner.github.io/0.10/config/configuration-file.html

One thing to notice is the framework that are being included in the configuration file.

There are other test runners out there (like Chutzpah) for front end code, but right now it seems that the best runner out there and the most common one is karma. Go with the flow J

While Mocha JS framework is great for node.js, it requires great deal of configuration for the front end use.

Test framework

There are a lot of testing framework out there, Qunit and Jasmine are the top leading solution. Choosing one of those is really question of taste and acquaintance. I chose jasmine, since I used it in other projects and it is more popular than Qunit in angular. But if you prefer Qunit it is OK, You will manage the changes in the syntax. Both have the same structure and the same principles behind them.

The documentation of jasmine can be found here: https://jasmine.github.io/edge/introduction.html

As well as jasmine, we will use angular.js API that part of it designed to be use inside automated tests: $httpBackend for example.

Running karma with Grunt

In our project we chose grunt as the JavaScript task runner. Although it have serious competition, grunt is solid and enterprise ready solution. If you are using gulp or another stuff it is OK, but you should stick to the rule that you must have command that run the karma test runner with the configuration.

After we installed grunt karma and made sure it is in the package.json. We also created karma task and karma config file:

// Test settings
 karma: {
 unit: {
 configFile: ‘karma.conf.js’,
 autoWatch: false,
 singleRun: true,
 browsers: [‘PhantomJS’]
 },
 debug: {
 configFile: ‘karma.conf.js’,
 autoWatch: true,
 singleRun: false,
 browsers: [‘Chrome’]
 }
 },

We will discuss the debug sub task later and focus on the karma:unit sub task. The main issue in it is that the browser is phantomJS.

Making sure that PhantomJS is installed

In order to make the phantomJs work, it should be installed on your machine separately. The install instructions are here:

https://phantomjs.org/download.html

If everything is OK, you should go to your CMD and type `phantomjs`. You should not get phantom: command not found But get to the phantomJS console.

Run karma

Running karma is quite easy?—?just type grunt karma:unit. It will run karma by the definitions in ‘karma.conf.js’.

Simple tests operations and structure

This guide purpose is not to write ‘hello world’ js. I assume that everyone that reads this manual wrote some tests, In JS or in other language. But I will explain briefly on the test structure here:

The basic test structure is combined of several parts:

Test suite?—?Tests are being organized in tests ‘groups’ or suite, for example all the tests that for specific directive are organized in one suite, the tests for single controllers etc.

In jasmine, all tests suite are wrapped in “describe”, in the describe the first argument is the name of this group. It should be unique and aligned with convention.

describe(‘Service: Content’, function () {
//The tests
{);

Before All

All the code that is included in this function will run ONCE before the test suite.

beforeAll(function () {
 }));

After All

All the code that is being included in this function will run ONCE after the test suit.

afterAll(function () {
 }));

Before Each

All the code in this function will be run once before each test. In this function we are basically doing the setup of the test?—?creating the controller, directive or service.

beforeEach(function () {
 }));

Injecting (or including) the angular services that we need for the testing. For example, if we test a controller, we will create this controller and create demo scope for it in the beforeEach.

beforeEach(inject(function($rootScope, $q){
}));

I will further explain about the beforeEach in the controller, directive and services tests.

AfterEach

Code that will run after each test?—?basically the tear down and eliminating global vars if needed.

afterEach(function () {
 }));

The test

The test itself is wrapped in “it” function. The “it” first argument is the name of the test, the name should be unique and plain enough.

it(‘should have some method’, function () {
 //the tests
 });

The code in the “it” function is running in isolated scope, if we want to pass parameter to the test, we have to do it by global vars.

Jasmine: expect

Jasmine testing framework is using expect. Expect is having one argument?—?the variable that is tested. This variable can be string, number, function, object, and array or null/undefined. Expect is chained to assertion functions for example:

expect(var).toBeDefined();
expect(var).toBeUnDefined();
expect(var).toEqual(333);
expect(var).toNotEqual(333);

Almost all assertion has opposite assertion. toEqual and toNotEqual.

You can do the opposite with chaining not to the expect. For example:

expect(data).not.toBe(null);

Debugging tests

Running only one test

You can run only one test using stuttering, for example:

iit(‘should have some method’, function () {
 //the tests
 });
ddescribe(‘Service: Content’, function () {
//The tests
{);

Running karma debug mode in the browser

As mentioned earlier, karma use phantom js browser for running the test. But we can use desktop browser and then use its inherited dev tool for debugging. How to do it? We created a grunt task for it call karma debug:

// Test settings
 karma: {
 unit: {
 configFile: ‘karma.conf.js’,
 autoWatch: false,
 singleRun: true,
 browsers: [‘PhantomJS’]
 },
 debug: {
 configFile: ‘karma.conf.js’,
 autoWatch: true,
 singleRun: false,
 browsers: [‘Chrome’]
 }
 },

The config file for karma debug and karma unit (the regular karma) are the same, but karma debug task is using chrome. You should install karma-chrome-launcher npm package to allow chrome to work with karma.

In the test (inside the `it` function) you can write

debugger;

And run grunt karma:debug.

You will notice that chrome will launch and then stop. Clicking of F12 will allow you to see all the variables that is existing in the test scope.

Controller tests

Basic usage

Let us create a spec file. The spec file for a controller will be the name of the controller with the “spec” before the JavaScript.

For example product-pages.controller.js => product-pages.controller.spec.js

We are using strict mode for the tests as well.

‘usestrict’;

Now, we will write down the name of the test suite:

describe(‘Controller: ProductPagesCtrl’, function () {
 
 
 });

The naming convention is Controller: NameOfController as it appears in the module.

The most crucial step in test is to prepare the tests themselves. It means to create the controller in some “virtual” space. With actual scope that we can alter and services etc. etc. This is why we will start by writing a beforeEach. This is a method that run before each individual test to prepare the environment.

beforeEach(
 function() {
 
 }
 );

In this function we are creating the scope and the controller:

beforeEach(
  function(){
    //Injection - all the services and scopes that I need
    inject(function($controller, $rootScope){
      $scope = $rootScope.$new();
      var OurCtrl = $controller('OurCtrl', {
        $scope: $scope
      });
    });
  }
);

What is happening here? We need $controller to create the controller and $rootscope to create a scope. This will run in every test. So we will have a controller and a scope! Yay!

But wait, I need to actually access the scope variable outside the beforeEach, because if the controller change something in the scope, I want to know about it. If the controller have a function in the scope, I need to activate it. How to do it? Well, we will create a global variable for the scope, the controller and later on every variable that I need to use outside of the beforeEach context:

'use strict';
describe('Controller: FileRepositoryCtrl', function () {
  //Global vars that can be used in all test cases in this files.var $scope,
    OurCtrl;
  // Initialize the controller and a mock scope
  beforeEach(
    function() {
      //Injection - all the services and scopes that I need
      inject(function ($controller, $rootScope) {
        $scope = $rootScope.$new();
        OurCtrl = $controller('OurCtrl', {
          $scope: $scope
        });
      });
    }
  );
});

Now it is time to actually write the test:

it('should contain some function', function(){
  expect($scope.SomeMethod).toBeDefined();
});

If the controller insert someMethod, the test see that it actually defined in the scope.

We can use any function that the controller put in the scope and test the results.

Mocking services

As you know, almost all controllers need services. In order to test the services-controller interaction, we need to create mock service and to see if the service is actually being called with the parameters that we think it should get.

Creating mock service

Go to test/mock directory to services.mock.spec.js and create something like that

(function(){
  angular.module('mockTheService', ['app.services']);
  angular.module('portalClientApp').factory('mockTheService',
    ['$q',
      function($q){
        var mockService = {};
        mockService.method = function(arg1, arg2){
          var deferred = $q.defer();
          deferred.resolve('Remote call result');
          return deferred.promise;
        };
        return mockService;
      }
    ]);
})();

The name of the mocking service should me mock + the actual name of the service. The methods should match exactly the service. Since we are using promises in our services, we have to return promise. Like in this example.

Using mock service

After we created the mock services, we need to inject it. We inject it to the controller like the scope.

'use strict';
describe('Controller: OurCtrl', function () {
  //Global vars that can be used in all test cases in this files.var OurCtrl,
    mockService,
    $scope;
  // Initialize the controller and a mock scope
  beforeEach(
    function() {
      //Injection - all the services and scopes that I need
      inject(function ($controller, $rootScope, _mockService_) {
        $scope = $rootScope.$new();
        mockService = _mockService_;
        OurCtrl = $controller('OurCtrl', {
          $scope: $scope,
          mockService: _mockService_
        });
      });
    }
  );
  it('should contain some function', function () {
    expect($scope.SomeMethod).toBeDefined();
  });
  
});

Spying on the mocked service

If we want to see that the mocked service is actually activated, we need to do three things:

1. Spy on the mocked service method. We do it by using this function in the beforeEach:

spyOn(mockService, 'method').and.callThrough();

2. Activate the method like regular function in the test itself:

mockService.method();

3. Use expect to see it was activated with the proper arguments.

expect(mockService.method).toHaveBeenCalledWith('arg1', 'arg2');

And here it is combined together:

'use strict';
describe('Controller: OurCtrl', function(){
  //Global vars that can be used in all test cases in this files.var OurCtrl,
    mockService,
    $scope;
  // Initialize the controller and a mock scope
  beforeEach(
    function(){
      //Injection - all the services and scopes that I need
      inject(function($controller, $rootScope, _mockService_){
        $scope = $rootScope.$new();
        mockService = _mockService_;
        spyOn(mockService, 'method').and.callThrough();
        OurCtrl = $controller('OurCtrl', {
          $scope: $scope,
          mockService: _mockService_
        });
      });
    }
  );
  it('should contain some function', function(){
    expect($scope.SomeMethod).toBeDefined();
    mockService.method();
    expect(mockService.method).toHaveBeenCalledWith('arg1', 'arg2');
  });
});

Pro tips on mock services spying

Use apply to actually activate mock services

If the controller have a service that run immediately in the beginning, you have to use

$scope.$apply(); //Scope apply let all promises fulfilled.

To actually run the mocked service and to see it is fulfilled. Here is an example:

it('should populate var 1 in scope', function(){
  //This method run in the start and fill $scope.var1
  expect(mockService.method).toHaveBeenCalledWith('arg1', 'arg2', 'arg3');
  $scope.$apply(); //Scope apply let all promises fulfilled.
  expect($scope.var1).toBeDefined();
});

Inject 3rd party services as usual

If we have 3rd party services that does not interact with another server, we should not create a mock for them but to inject them and use them as usual. For example, sce (https://docs.angularjs.org/api/ng/service/$sce) for angular is not need to be mocked unless you have a real reason to check what is passed through it.

Directive tests

Basic usage

Testing directive is very easy, the process is simple?—?compile the directive into HTML element, like regular directive. After that, gain access to the directive isolated scope to test the directive scope’s variables and methods.

In the directive directory, we will create spec.js like the following example

myDirective.directive.js tests Will be in myDirective.directive.spec.js

We are using strict mode in every test:

'use strict';

The first thing that we are doing is to create the test suite?—?i.e. the tests collection name:

describe('Directive: myDirective', function () {
});

Inside the “describe” method we will have all the tests and the preparing for each test. Each test will need a basic setup?—?for example fresh scope where we can create our new directive. Also, after each test we will need to destroy this fresh scope. Those are achieved by beforeEach and afterEach methods that run before and after each tests.

describe('Directive: myDirective', function () {
  beforeEach(function() {
  });
  afterEach(function() {
  });
});

We will use rootScope to create a new scope and compile to help us compile the directive. We will need to inject those and make them available to each test. The injection is being done like any other angular injection.

describe('Directive: myDirective', function(){
  beforeEach(function(){
    inject(function($rootScope, $compile){
      compile = $compile;
      scope = $rootScope.$new();
    });
  });
  afterEach(function(){
  });
});

We are making those available to the test by inserting those to global variables.

describe('Directive: myDirective', function(){
  var compile,
    scope;
  beforeEach(function(){
    inject(function($rootScope, $compile){
      compile = $compile;
      scope = $rootScope.$new();
    });
  });
  afterEach(function(){
  });
});

After each test we want to purge the scope, we are doing that by using destroy in each afterEach:

describe('Directive: myDirective', function(){
  var compile,
    scope;
  beforeEach(function(){
    inject(function($rootScope, $compile){
      compile = $compile;
      scope = $rootScope.$new();
    });
  });
  afterEach(function(){
    scope.$destroy();
  });
});

Now it is time to write the tests! Each test is wrapped in “it” function. The name of the function is the name of test:

it('should contain myMethod method in scope', function() {
});

The first thing that we need to do is to generate the directive and insert it to the scope (remember? We creating the scope in the beforeEach).

it('should contain myMethod method in scope', function(){
  //Generating the directivevar element = angular.element('<my-directive></my-directive>');
  element = compile(element)(scope);
  scope.$digest();
});

In the element we insert out directive. If it is attribute directive, we insert it like HTML element:

//Generating the directive
element = angular.element('<span my-directive attr1="attr1"></span>');
element = compile(element)(scope);
scope.$digest();

Inject services, constant to directive

Some directive use services, in order to mock those services, we have to provide those in the directives. In the beforeEach method, we will call to the module of our app. For example ‘portalClientApp’, and then we will inject the $provide. With the provide we can create whatever service, constant or factory that we want to.

Here for example, I inject constant to the provider.

module('portalClientApp', function($provide){
  $provide.constant('envVariables', {assetServer: ‘someURL’});
});

Testing directive inner scope

Now we need to gain access to the directive inner scope, it is being done by two ways.

Non replaced element

If it is a replaced directive?—?i.e. the directive HTML NOT replace the directive code (replace: false in the directive definition), we will use this code:

//Generating the directivevar element = angular.element('<my-directive></my-directive>');
element = compile(element)(scope);
scope.$digest();
var isolatedScope = element.children().scope();

The element is the directive and the children are the HTML itself that need to be tested.

Replaced elements

If the replace is true, it is simpler:

var isolatedScope = element.isolateScope();

The isolated scope contain all the directive methods and variables and can be tested with expect module.

describe('Directive: myDirective', function(){
  var compile,
   scope;
  beforeEach(function(){
    inject(function($rootScope, $compile){
      compile = $compile;
      scope = $rootScope.$new();
    });
  });
  it('should contain myMethod method in scope', function(){
    //Generating the directivevar element = angular.element('<my-directive></my-directive>');
    element = compile(element)(scope);
    scope.$digest();
    var isolatedScope = element.children().scope();
    var result = isolatedScope.myMethod();
    expect(result).toEqual('realResult');
  });
  afterEach(function(){
    scope.$destroy();
  });
});

Testing directive HTML

Basically, Testing should be focused on the isolated scope of the directive and not on the HTML. If you have some button that activate some method in your directive’s controller. Test directly this method, not simulate click on the button.

But some directive just print out HTML and not having methods or variable. You can analyze the HTML of this directory by using jQuery lite. See that code for example:

//Defining HTML element to be tested.var items = element.find('.list-group-item');
//Making sure that the list is populated according to options array.
expect(items.length).toBe(4);
expect(items.eq(0).text()).toContain('item 1');
expect(items.eq(1).text()).toContain('item 2');
expect(items.eq(2).text()).toContain('item 3');
expect(items.eq(3).text()).toContain('item 4');
expect(items.eq(0).text()).toContain('Remove item 1');
expect(items.eq(1).text()).toContain('Remove item 2');
expect(items.eq(2).text()).toContain('Remove item 3');
expect(items.eq(3).text()).toContain('Remove item 4');

In any part of the way you can console.info the element to observe the HTML. You can use click or any other jQuery lite events:

firstItem.find('.icon-trash').click();

All jQuery lite methods can be found here:

https://docs.angularjs.org/api/ng/function/angular.element

Using scope apply

Do not forget to call

scope.$apply();

Whenever you “do” something the controller?—?for example change something the global scope variable or clicking on something.

Why? Because In a real angular app, the $digest is automatically triggered by angular in reaction to several events (clicks, hover, etc.). But in karma it does not doing that, because there are no real user-based events. That is why during the automated tests so we just need to force the $apply.

Service tests

Basic usage

Services is a little tricky to test, since we need to emulate the API response to it. Every service is connected to a backend server. During the test, we need to emulate the backend server. The tests should be isolated, a truly unit test that can work regardless of the backend, so we emulate the backend and create a ‘mock backend’.

We will locate the service and create test file, the naming convention for these names are the same as the service but with spec.js.

For example: myService.service.js => myService.service.spec.js

All our tests are written with strict mode:

'use strict';

We will start with describe. As you saw in “controller” and “directive”, describe is being used to… well… describe the tests suite. Inside the describe there is a beforeEach method, that is running before each test. In the beforeEach we are making the test setup. In this case injecting service that we want to test and other things. For example: constants that we have, or $httpBackend that is useful for mocking backend.

'use strict';
describe('Service: MyService', function () {
  var REST_BASE, myService, $httpBackend;
beforeEach(inject(function (_myService_, _$httpBackend_, _REST_BASE_) {
  myService = _myService_;
  $httpBackend = _$httpBackend_;
  REST_BASE = _REST_BASE_;
}));
});

This requires some explanation, isn’t it?

Here we inject 3 services: myService, $httpBackend and REST_BASE which is actually angular constant. But why O why we are using _X_ syntax?!?

We are using it because I insert all to the global variable. You can see the declaration:

 var REST_BASE, contentService, $httpBackend;

It is just below describe. In the beforeEach injection, I insert all the injected stuff to these variables, if I will do something like that:

'use strict';
describe('Service: MyService', function () {
  var REST_BASE, myService, $httpBackend;
beforeEach(inject(function (myService, _$httpBackend_, _REST_BASE_) {
  myService = myService;
  $httpBackend = $httpBackend;
  REST_BASE = REST_BASE;
}));
});

I will get wonderful error. Because you can’t really do that stuff:

myService = myService;
 $httpBackend = $httpBackend;
 REST_BASE = REST_BASE;

So angular in its infinite wisdom, allow us to inject with _ prefix and suffix. Nice, isn’t it?

After we finish to write the beforeEach and inject the service, we can wrote the test itself while using the regular it and expect:

describe('Service: MyService', function(){
  var REST_BASE, myService, $httpBackend;
  beforeEach(inject(function(_myService_, _$httpBackend_, _REST_BASE_){
    myService = _myService_;
    $httpBackend = _$httpBackend_;
    REST_BASE = _REST_BASE_;
  }));
  it('should contain myMethod method', function(){
    expect(myService.myMethod).toBeDefined();
  });
});

When we are calling service, we have to use this method:

$httpBackend.flush();

To actually activate it.

Mocking backend

Since most of the services are working with a 3rd party backend, we need to emulate those backend services. We are doing this emulation because this are unit testing, and unit testing should run independently, and we should not connected to the backend during the test.

Mocking GET

Basic usage

Mocking GET request is quite easy and is done by $httpBackend?—?angular antive service.

$httpBackend
  .whenGET('/somepath/?arg1=arg1')
  .respond(200, {1234});

It is self-explanatory?—?when the service try to call this path, it will not get out but immediately get the response, in that case 1234, but it can be anything: string, number, object?—?whatever.

$httpBackend

 .whenGET(_REST_BASE + ‘/somepath/?arg1=arg1’)

 .respond(200, someResponse);

When VS Expect

There is huge difference between:

$httpBackend

 .whenGET(_REST_BASE + ‘/somepath/?arg1=arg1’)

 .respond(200, someResponse);

And

$httpBackend

 .expectGET(_REST_BASE + ‘/somepath/?arg1=arg1’)

 .respond(200, someResponse);

WhenGET is responding to the API call, but will do nothing if no call is actually received. expectGET will fail the test if an API call is not being received.

Testing payload

If you want to verify the payload, pass it as second attribute.

$httpBackend
  .whenGET('/somepath/?arg1=arg1', {payload: 'value'}
)
  .respond(200, {1234});

Testing PUT, DELETE, POST etc.

Just replace the GET with the API call, for example:

$httpBackend
  .whenPOST('/somepath/?arg1=arg1', {payload: 'value'}
)
  .respond(200, {1234});

Summary

I’ve described here how to write tests for Angular 1.x controllers, directive and services. This is not ‘hello world’ guide but a brief explanation on how to write tests on a real application, including setting up grunt (or gulp) and debugging.

Ellen Shu

Application Developer at Edmonton Police Service

1 年

expect(myService.myMethod).toBeDefined(); How to write a test on the return from myMethod(1,'astring')?

回复
Ron Apelbaum

Director of R&D at Fullpath (formerly AutoLeadStar)

8 年

greate summary!

要查看或添加评论,请登录

社区洞察

其他会员也浏览了