Proper UNIT testing

of Angular JS

with (ES6) modules

TOMAS HERICH

Software Engineer Frontend / Java

  • more than two years of professional Angular JS experience
  • projects of various sizes and domains
  • Frontend / Angular JS focused blog
  • affinity for frontend build systems
  • reddit/r/ javascriptprogramming,  angularjs addict

OVERVIEW

STANDARD WAY OF TESTING ANGULAR JS APPs

WHAT IS THE ANGULAR CONTEXT

WHY IS ANGULAR CONTEXT A PROBLEM

WHAT IS THE ROLE OF (ES6) MODULEs


HOW TO DO PROPER UNIT TESTING

HOW TO DEAL WITH DEPENDENCIES

WHAT IS THE ROLE OF KARMA / JASMINE

1.

2.

3.

4.

 

5.

6.

7.

 

 

BUT FIRST...

LET ME TELL YOU A SHORT STORY

ABOUT TESTING

in TWO DIFFERENT PROJECTS...

PROJECT 1

PROJECT 2

32

sec

6000+ tests

1100+ tests

157

sec

~

~

THE PLOT

THICKENS...

PROJECT 1

PROJECT 2

?

stack

framework

library

environment

dependencies

FIN

IT WAS A SHORT STORY INDEED...

PROJECT 1

PROJECT 2

Angular JS

Grunt

Karma

Jasmine

Chrome

Node.js

Grunt

Mocha

Chai

BUT WHY !?

THE STANDARD WAY

OF TESTING ANGULAR JS APPLICATIONS

INNOCENT EXAMPLE

OF A SIMPLE KARMA JASMINE TEST

describe('TodoService', function () {

    var service;

    beforeEach(module('main'));

    beforeEach(module(function ($provide) {
        $provide.constant('initialTodos', []);
    }));

    beforeEach(inject(function (_TodoService_) {
        service = _TodoService_;
    }));

    it('should contain empty todos after initialization', function() {
        expect(service.todos.length).toBe(0);
    });

    it('should add todo', function () {
        service.addTodo('Finish example project');
        expect(service.todos.length).toBe(1);
        expect(service.todos[0].label).toBe('Finish example project');
    });

    // ... more tests

});

looks like

every angular js testing tutorial on the web

WHAT IS THE CATCH ?

Angular context

What it is and why it is important

CONTEXT

INITIALIZED ANGULAR JS

DEPENDENCY INJECTION

MECHANISM

=

Angular context

What it is and why it is important

DEPENDENCY  INJECTION 

WORKS BY REGISTERING EVERYTHING ON GLOBAL ANGULAR OBJECT

BY USING EXPOSED API LIKE MODULE, CONTROLLER, FACTORY…

Angular context

EXAMPLE & WHY

// global angular object

angular
    
    // registration APIs

    .module('app', [])
    .controller('MyController', function() {})
    .factory('MyFactory', function() {})
    .service('MyService', function() {});

    // directive, provider ...
  • historical reasons
  • no module system
  • build by concatenation
  • global namespace pattern

Angular context

AS SEEN IN SOURCE CODE

describe('TodoService', function () {

    var service;

    beforeEach(module('main'));

    beforeEach(module(function ($provide) {
        $provide.constant('initialTodos', []);
    }));

    beforeEach(inject(function (_TodoService_) {
        service = _TodoService_;
    }));

    it('should contain empty todos after initialization', function() {
        expect(service.todos.length).toBe(0);
    });

    it('should add todo', function () {
        service.addTodo('Finish example project');
        expect(service.todos.length).toBe(1);
        expect(service.todos[0].label).toBe('Finish example project');
    });

    // ... more tests

});

initialize app context

mock dependencies

get reference to tested object

test

GETTING A REFERENCE

CAN BE TOUGH

describe('TodoService', function () {

    var scope, $rootScope, $controller;

    beforeEach(module('main'));

    beforeEach(module(function($provide) {
        var TodoServiceMock = {} // mock TodoServie
        $provide.value('TodoService', TodoServiceMock);
    }));

    beforeEach(inject(function (_$controller_, _$rootScope_, _TodoService_) {
        $controller = _$controller_;
        $rootScope = _$rootScope_;
        scope = $rootScope.$new();

        $controller('TodoController', {
            $scope: scope
            TodoService: _TodoService_
        });
    }));
    
    it('should have initial todos', function() {
        expect(scope.todos.length).toBe(1);
    });

    // ... more tests
});

initialize app context

mock dependencies

get reference to tested object

test

LIKE REALLY TOUGH

IF YOU WANT TO TEST DIRECTIVE'S CONTROLLER

describe('TodoService', function () {

    var directive, scope, $compile, $rootScope;

    beforeEach(module('main'));

    beforeEach(inject(function (_$compile_, _$rootScope_) {
        $compile = _$compile_;
        $rootScope = _$rootScope_;
        scope = $rootScope.new();

        var element = angular.element('<div todo-component></div>');
        var directive = compile(element)(scope);
        scope.$digest();
    }));
    
    it('should have initial todos', function() {
        var button = directive.find('#remove-done-todos');
        button.triggerHandler('click');
        scope.$digest();

        expect(scope.todos.length).toEqual(0);
    });

    // ... more tests
});

initialize app context

compile template

get reference to element ?!

MASOCHISM

ANGULAR

CONTEXT

TESTING

Spec.execute

PROFILER's OPINION

the problems

1

Angular context module(‘app’) must be instantiated before every test

Context must be available to be able to do any testing at all even though the functionality may be in form of pure functions / classes not using any Angular specific API. Without Angular context you can’t get access (reference) to your controllers / services.

2

Even with Angular context, it is still hard to get references to some units (directive's controller)

Compile inline template, inject scope, fire events on DOM elements, vs 

registering directive's controller as .controller() in Angular directly

3

Angular and all other used libraries must be included during testing

...so it is even possible to instantiate Angular context

4

Angular context can grow

QUITE LARGE*

It’s creation will then consume considerable amount of time for every test file (beforeEach(module('app')))

* it is possible to split application into submodules and instantiate only the deepest submodule but this adds overhead of registering and maitaining of submodules and you still may need to instantiate whole app to get references to cross-cutting concerns like logging, exception handling or other infrastructure services

?

SOLUTION

the (es6*) modules

And the solution they offer

*

Works with any module system out there, but with the ES6 being officially released and also adopted by Typescript I would say it is the choice which makes most sense right now


// beautiful ES6 import statement

import TodoService from './todo.service.js';

MODULE SYNTAX

SO WHAT?

  • implementation self-contained in a file (modules)
  • explicit import / export syntax
  • module bundlers wrapping files (modules) in their own DI

=

2 DEPENDENCY INJECTION MECHANISMS IN ONE PROJECT

LET'S COMPARE

DI OF ANGULAR JS VS MODULES

VS

  • global object (namespace)
  • registration API
  • concat friendly
  • framework specific
  • hard to get reference
  • transparent DI wrapper
  • standard syntax
  • concat friendly (bundlers)
  • framework agnostic
  • easy to get reference

MODULES

ARE NICE

HOW CAN WE

USE THEM ?

the proper unit test

No ANGUALR CONTEXT, no di, just your code

import { assert } from 'chai';

import TodoService from './todo.service.js';

let service;

describe('TodoService', function() {

    beforeEach(function() {
        service = TodoService();
    });

    it('should contain empty todos after initialization', function () {
        assert.equal(service.todos.length, 0);
    });

    it('should add todo', function () {
        service.addTodo('Finish example project');
        assert.equal(service.todos.length, 1);
        assert.equal(service.todos[0].label, 'Finish example project');
        assert.equal(service.todos[0].done, false);
    });

});

WHAT HAVE WE DONE?

IT CAN't GET ANY SIMPLER

As you could see, test just imports the service and well… tests it! 

No Angular context or API whatsoever, just as it should be because we are unit testing the service functionality not the Angular’s dependency injection mechanism.

HOW TO #1: SERVICE

IMPLEMENTATION IS STRAIGHT FORWARD

import * as _ from 'lodash';

export default function TodoService(initialTodos) {

    const todos = initialTodos;

    return {
        todos,
        addTodo,
        toggleTodo,
        removeDoneTodos
    };

    function addTodo(label) {
        let todo = {
            label,
            done: false
        };
        todos.push(todo);
    }
    
    // other methods ...

}

HOW TO #2: ANGULAR

REGISTER SERVICE INTO ANGULAR CONTEXT

import angular from 'angular';

import TodoService from './services/todo.service';

export default angular
    .module('main.app.feature-b', [])
    .factory('TodoService', TodoService)
    .name;

// other controllers, directives, etc..

HOW TO #3: TESTS

JUST IMPORT SERVICE AND TEST IT

  • assert syntax
  • expect syntax
  • utility methods
  • simple
  • GREAT async support
  • (just return a promise)

HOW TO #4: DEPENDENCIES

WHAT ABOUT THE UNITS WITH DEPENDENCIES ?

// imports skipped for increased brevity

describe('TodoComponent with mocked service (unit test)', function() {

    beforeEach(function() {
        let initialTodos = [];
        let TodoServiceInstance = TodoService(initialTodos);
        mockTodoService = sinon.mock(TodoServiceInstance);
        component = new TodoComponent(TodoServiceInstance);
    });

    afterEach(function() {
       mockTodoService.restore();
    });

    it('should add todo', function () {
        mockTodoService
            .expects('addTodo')
            .once()
            .withArgs('Finish example project');

        component.label = 'Finish example project';
        component.addTodo();

        mockTodoService.verify();
    });
});

HOW TO #5: INTEGRATION

SMALL SCALE MANUAL INTEGRATION TESTING

import { assert } from 'chai';

import TodoComponent from './todo-component.js';
import TodoService from '../services/todo.service.js';

let component;

describe('TodoComponent with real service (Integration test)', function() {

    beforeEach(function() {
        let initialTodos = [];
        let todoService = TodoService(initialTodos);
        component = new TodoComponent(todoService);
    });

    it('should add todo', function () {
        component.label = 'Finish example project';
        component.addTodo();
        assert.equal(component.label, '');
        assert.equal(component.todos.length, 1);
        assert.equal(component.todos[0].label, 'Finish example project');
        assert.equal(component.todos[0].done, false);
    });

});

INTEGRATION TESTS

AS THEY SHOULD BE

Karma / Jasmine have proven to be great at instantiating of whole Angular JS application context in a browser 

INTEGRATION TESTS

WHEN AND HOW TO USE THEM ?

  • testing of shared infrastructure services
  • testing of UI interactions
  • testing of directives (DOM manipulation)
  • $httpBackend
  • ...
  • how the standard Angular JS Karma / Jasmine tests work

  • what is Angular JS context

  • why it exits and how it relates to testing

  • how to use (ES6) modules to circumvent Angular JS context

  • how to unit test functionality using Mocha

  • how to split code between implementation and registration into Angular JS context

  • when and why to use Karma / Jasmine integration tests

THAT's IT

LET'S SUMMARIZE WHAT WE HAVE LEARNED

THANK YOU

I HOPE YOU ENJOYED THE PRESENTATION

TOMAS HERICH

Software Engineer Frontend / Java

Proper testing of Angular JS applications with (ES6) modules

By Tomáš Trajan

Proper testing of Angular JS applications with (ES6) modules

Testing of Angular JS application used to be quite painful especially when using “official” solutions like Karma or Protractor. ES6 (aka ES2015, aka new Javascript release) changed this by introducing standardized module syntax. This enables us to do real unit testing of Angular JS constructs like controllers, factories or services in a very simple and fast fashion.

  • 9,258