Unit testing AngularJS

Dr. Gleb Bahmutov PhD
Kensho Boston / NYC

Speaker details

100s of OSS projects at glebbahmutov.com/

100s of blog posts at glebbahmutov.com/blog/

@bahmutov

My favorite subject: testing

@bahmutov Kensho

Testing?

@bahmutov Kensho

Testing is boring

@bahmutov Kensho

Testing is a chore

@bahmutov Kensho

Testing is a drag

@bahmutov Kensho

Write 100 tests and earn $0

Why invest in writing an OSS testing library?

@bahmutov Kensho

How to earn money

  • Write working code

  • Iterate

  • Profit

tests?

@bahmutov Kensho

How to earn money

  • Write working code

  • Iterate

  • Profit

tests

@bahmutov Kensho

Agile / iteration dev model

@bahmutov Kensho

Agile / iteration dev model

Write code

Refactor (iterate)

get paid

@bahmutov Kensho

Agile / iteration dev model

Write code

Refactor (iterate)

get paid

@bahmutov Kensho

Agile / iteration dev model

Write code

Write tests

Refactor (iterate)

get paid

Keep the app working

@bahmutov Kensho

Agile / iteration dev model

Write tests

Write code

Refactor (iterate)

get paid

Test Driven Development

@bahmutov Kensho

Agile / iteration dev model

Write tests

Refactor

Write code

get paid

@bahmutov Kensho

Effective testing

  1. Write fewer tests

  2. Spend less time writing tests

@bahmutov Kensho

Write fewer tests

  • Test things that can be tested quickly

  • Test things that MUST work

@bahmutov Kensho

Background: test sizes

Unit tests: small, fast

E2E tests: large, slow

use Protractor (not in this presentation)

use Karma

@bahmutov Kensho

Sample Mocha unit test

function add(a, b) { 
    return a + b; 
}
describe('addition', function () {
});
  it('adds numbers', function () {
    expect(add(2, 3)).toEqual(5);
  });
  it('concatenates', function () {
    expect(add('foo', 'bar')).toEqual('foobar');
  });

add.js

add-spec.js

unit test

unit test

@bahmutov Kensho

Karma configuration

$ npm install -g karma
$ karma init
$ npm install karma-mocha
module.exports = function(config) {
  config.set({
    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
    frameworks: ['mocha'],
    files: [
      'add.js', 'add-spec.js'
    ],
    // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
    preprocessors: {
    },
    reporters: ['progress'],
    // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
    browsers: ['Chrome']
  });
};
$ karma start

@bahmutov Kensho

Single test run

$ karma start --single-run=true
INFO [karma]: Karma v0.12.21 server started at http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 44.0.2403 (Mac OS X 10.10.2)]: 
    Connected on socket 6Hp2TOIipFToLo8rgaIk with id 32903062
Chrome 44.0.2403 (Mac OS X 10.10.2): 
    Executed 2 of 2 SUCCESS (0.007 secs / 0.004 secs)

@bahmutov Kensho

Karma bonus: coverage

Have we tested everything?

$ npm install karma-coverage --save-dev
// karma.conf.js
module.exports = function(config) {
  config.set({
    ...
    preprocessors: {
      '*.js': 'coverage'
    },
    reporters: ['progress', 'coverage'],
    ...
  });
};

Tip: use coverage preprocessor for code only, not for test files

@bahmutov Kensho

Karma bonus: coverage

$ karma start --single-run=true
$ open coverage/Chrome/index.html

@bahmutov Kensho

Hint: use code coverage to guide testing

Test complex large files first

filename     lines   coverage
------------------------------
foo.js        100       25%
bar.js         10       20%

@bahmutov Kensho

Test complex large files (risk-map)

@bahmutov Kensho

Test complex large files (risk-map)

@bahmutov Kensho

Alternate strategy

  • Build feature bundles

  • Coverage by bundle

bundle         unit test coverage
----------------------------------
dist/foo.js          40%
dist/bar.js          70%
dist/baz.js          10%

@bahmutov Kensho

Bonus

bahmutov/was-tested - code coverage from live websites

Bonus

bahmutov/tested-commits - splits code coverage info from any source by commit

bundle         total coverage = unit tests + live
--------------------------------------------------
dist/foo.js          40%           35%        5%
dist/bar.js          70%           35%       35%
dist/baz.js          10%           10%        0%

@bahmutov Kensho

We can target unit tests better

Writing fewer unit tests helps, but we still need to spend time writing them

@bahmutov Kensho

Fast test writing

@bahmutov Kensho

Injectable

The dependency injection in AngularJS allows you to declaratively describe how your application is wired. This means that your application needs no main() method which is usually an unmaintainable mess. Dependency injection is also a core to AngularJS. This means that any component which does not fit your needs can easily be replaced.

Testable

AngularJS was designed from ground up to be testable. It encourages behavior-view separation, comes pre-bundled with mocks, and takes full advantage of dependency injection. It also comes with end-to-end scenario runner which eliminates test flakiness by understanding the inner workings of AngularJS.

Q: Is AngularJS easy to test?

@bahmutov Kensho

TLDR:   AngularJS is testable; its testability is directly due to the dependency injection mechanism

@bahmutov Kensho

angular.module('TodoApp', [])
  .controller('TodoController', function ($scope) {
    $scope.todos = ['do this', 'do that'];
  });
// <li ng-repeat="todo in todos">{{ todo }}</li>

Todo example (controller uses a scope)

Let's test!

angular-mocks - inject modules into tests, sync methods

Angularjs - Unit testing introduction by Nic Kaufman

1, 2, 3, tested ng-describe tutorial

@bahmutov Kensho

describe('standard unit test', function () {
  beforeEach(angular.mock.module('TodoApp'));
  var $controller, $rootScope;
  beforeEach(angular.mock.inject(
    function (_$controller_, _$rootScope_) {
        $controller = _$controller_;
        $rootScope = _$rootScope_;
    }
  ));
  it('creates a controller', function () {
    var scope = $rootScope.$new();
    $controller('TodoController', {
      $scope: scope
    });
    expect(scope.todos.length).toEqual(2, 'has 2 todo items');
  });
});
angular.module('TodoApp', [])
  .controller('TodoController', function ($scope) {
    $scope.todos = ['do this', 'do that'];
  });

Unit testing Todo example

angular.module('TodoApp', [])
  .controller('TodoController', function ($scope) {
    $scope.todos = ['do this', 'do that'];
  });

Unit testing Todo example

describe('standard unit test', function () {
  beforeEach(angular.mock.module('TodoApp'));
  var $controller, $rootScope;
  beforeEach(angular.mock.inject(
    function (_$controller_, _$rootScope_) {
        $controller = _$controller_;
        $rootScope = _$rootScope_;
    }
  ));
  it('creates a controller', function () {
    var scope = $rootScope.$new();
    $controller('TodoController', {
      $scope: scope
    });
    expect(scope.todos.length).toEqual(2, 'has 2 todo items');
  });
});

The same dependency injection that makes AngularJS great fights you during testing

describe('standard unit test', function () {
  beforeEach(angular.mock.module('TodoApp'));
  var $controller, $rootScope;
  beforeEach(angular.mock.inject(
    function (_$controller_, _$rootScope_) {
        $controller = _$controller_;
        $rootScope = _$rootScope_;
    }
  ));
  it('creates a controller', function () {
    var scope = $rootScope.$new();
    $controller('TodoController', {
      $scope: scope
    });
    expect(scope.todos.length).toEqual(2, 'has 2 todo items');
  });
});

The same scope magic that makes AngularJS great fights you during testing

describe('standard unit test', function () {
  beforeEach(angular.mock.module('TodoApp'));
  var $controller, $rootScope;
  beforeEach(angular.mock.inject(
    function (_$controller_, _$rootScope_) {
        $controller = _$controller_;
        $rootScope = _$rootScope_;
    }
  ));
  it('creates a controller', function () {
    var scope = $rootScope.$new();
    $controller('TodoController', {
      $scope: scope
    });
    expect(scope.todos.length).toEqual(2, 'has 2 todo items');
  });
});

Goal: start testing without boilerplate

describe('standard unit test', function () {
  beforeEach(angular.mock.module('TodoApp'));
  var $controller, $rootScope;
  beforeEach(angular.mock.inject(
    function (_$controller_, _$rootScope_) {
        $controller = _$controller_;
        $rootScope = _$rootScope_;
    }
  ));
  it('creates a controller', function () {
    var scope = $rootScope.$new();
    $controller('TodoController', {
      $scope: scope
    });
    expect(scope.todos.length).toEqual(2, 'has 2 todo items');
  });
});
ngDescribe({
  module: 'TodoApp',
  controller: 'TodoController',
  tests: function (deps) {
    it('creates a controller', function () {
      expect(deps.TodoController.todos.length).toEqual(2);
    });
  }
});

same unit testing using ng-describe

kensho/ng-describe/test/todos-spec.js

Beautiful 

@bahmutov Kensho

ng-describe adds the missing "just works" on top of angular-mocks

ngDescribe({
  module: 'TodoApp',
  controller: 'TodoController',
  tests: function (deps) {
    it('creates a controller', function () {
      expect(deps.TodoController.todos.length).toEqual(2);
    });
  }
});

AngularJS (magically) creates controller and $scope for you

ng-describe (magically) creates controller and $scope for you during testing

angular.module('TodoApp', [])
  .controller('TodoController', function ($scope) {
    $scope.todos = ['do this', 'do that'];
  });
ngDescribe({
  module: 'TodoApp',
  controller: 'TodoController',
  element: '<myDirective ....></myDirective>',
  parentScope: { ... },
  inject: ['foo', 'bar', '$q'],
  tests: function (deps) {
    // deps will have everything that ng-describe creates
    it('works', function () {
      ...
    });
  }
});

ng-describe can create everything for you

ngDescribe recreates everything from scratch before every unit test

@bahmutov Kensho

ngDescribe({
  module: 'TodoApp',
  controller: 'TodoController',
  element: '<myDirective ....></myDirective>',
  parentScope: { ... },
  inject: ['foo', 'bar', '$q'],
  tests: function (deps) {
    beforeAll(function ...);
    beforeEach(function ...);

    it('works', function () {
      ...
    });

    afterEach(function ...);
    afterAll(function ...);
  }
});

ng-describe works with BDD callbacks

@bahmutov Kensho

ng-describe removes boilerplate in great majority of unit test scenarios

Can we compute boilerplate / code ratio?

ngDescribe({ // Boilerplate
  module: 'TodoApp', // Code

@bahmutov Kensho

original vs ng-describe

original boilerplate / code ratio = 2.7

describe('standard unit test', function () { // B
  beforeEach(angular.mock.module('TodoApp')); // C
  var $controller, $rootScope; // B
  beforeEach(inject(function (_$controller_, _$rootScope_) { // B
    $controller = _$controller_; // B
    $rootScope = _$rootScope_; // B
  }));
  it('creates a controller', function () { // B
    var scope = $rootScope.$new(); // B
    $controller('TodoController', { // C
      $scope: scope // B
    });
    expect(scope.todos.length).toEqual(2, 'has 2 todo items'); // C
  });
}); // B / C = 8 / 3 = 2.67

@bahmutov Kensho

high overhead => larger unit tests

Trying to cram lots of assertions into single test

describe('standard unit test', function () {
  beforeEach(angular.mock.module('TodoApp'));
  var $controller, $rootScope;
  beforeEach(inject(function (_$controller_, _$rootScope_) {
    $controller = _$controller_;
    $rootScope = _$rootScope_;
  }));
  it('creates a controller', function () {
    var scope = $rootScope.$new();
    $controller('TodoController', {
      $scope: scope
    });
    expect(Array.isArray(deps.TodoController.todos)).toBe(true);
    expect(scope.todos.length).toEqual(2, 'has 2 todo items');
    expect(...);
    expect(...);
  });
});

@bahmutov Kensho

ng-describe is boilerplate buster

ngDescribe({ // B
  module: 'TodoApp', // C
  controller: 'TodoController', // C
  tests: function (deps) { // B
    it('creates a controller', function () { // B
      expect(deps.TodoController.todos.length).toEqual(2); // C
    });
  }
}); // B / C = 3 / 3 = 1.0

ng-describe boilerplate / code ratio = 1

@bahmutov Kensho

Low overhead => smaller unit tests

ngDescribe({
  module: 'TodoApp',
  controller: 'TodoController',
  tests: function (deps) {

    it('has a list of todos', function () {
      expect(Array.isArray(deps.TodoController.todos)).toBe(true);
    });

    it('has two todos', function () {
      expect(deps.TodoController.todos.length).toEqual(2);
    });
  }
});

@bahmutov Kensho

I want it!

npm install ng-describe --save-dev

// karma.conf.js
files: [
    'node_modules/angular/angular.js',
    'node_modules/angular-mocks/angular-mocks.js',
    'node_modules/ng-describe/dist/ng-describe.js',
    '<your source.js>',
    '<your specs.js>'
],
frameworks: ['mocha'], // or jasmine

If using Angular 1.x, have to use angular-mocks 1.x too

@bahmutov Kensho

ng-describe API

ngDescribe(optionsObject);
// single function taking 
// a single options object

All properties in the options object: kensho/ng-describe#api

@bahmutov Kensho

ng-describe only assumes BDD globals: describe, beforeEach, afterEach

beforeAll, afterAll, fit, it.only, xit - they all work, ng-describe does not affect them

@bahmutov Kensho

Q: Mocha or Jasmine?

  • Jasmine has broken afterEach / afterAll callbacks
  • Jasmine's async test support is bad

@bahmutov Kensho

Recommended: Mocha + lazy-ass + check-more-types

angular.module('A', [])
  .value('foo', 'bar');
ngDescribe({
  module: 'A',
  inject: 'foo',
  tests: function (deps) {
    it('has foo', function () {
      la(check.has(deps, 'foo'), 'foo was injected');
      la(check.equal(deps.foo, 'bar'), 'foo is "bar"');
    });
  }
});

bahmutov/lazy-ass - lazy assertion message formatting

kensho/check-more-types - lots of predicates

@bahmutov Kensho

ng-describe examples

See all examples at kensho/ng-describe

Filter

Async

Directive

Mock $http

Mock everything

...

@bahmutov Kensho

Testing $filter

ngDescribe({
  name: 'built-in filter',
  inject: '$filter',
  tests: function (deps) {





  }
});
    it('can convert to lowercase', function () {
      var lowercase = deps.$filter('lowercase');
      la(lowercase('Foo') === 'foo');
    });

@bahmutov Kensho

Test async stuff

ngDescribe({
  inject: ['$q', '$rootScope'],
  tests: function (deps) {









  }
});

$rootScope - boilerplate digest cycle code!

    it('can be resolved', function (done) {
      deps.$q.when(42).then(function (value) {
        expect(value).toEqual(42);
        done();
      });
      // move promises along
      deps.$rootScope.$digest();
    });

@bahmutov Kensho

Test async stuff

ngDescribe({
  inject: '$q',
  tests: function (deps) {
    it('can be resolved', function (done) {
      deps.$q.when(42).then(function (value) {
        expect(value).toEqual(42);
        done();
      });
      // move promises along
      deps.step();
    });
  }
});

using deps.step() shortcut

@bahmutov Kensho

Test directive

angular.module('MyFoo', [])
  .directive('myFoo', function () {
    return {
      restrict: 'E',
      replace: true,
      template: '<span>{{ bar }}</span>'
    };
  });

@bahmutov Kensho

Test directive

ngDescribe({
  name: 'MyFoo directive',
  modules: 'MyFoo',
  element: '<my-foo></my-foo>',
  // you can also setup custom parent scope
  tests: function (deps) {
     







  }
});
    it('can update DOM using binding', function () {
      la(check.has(deps, 'element'));
      var scope = deps.element.scope();
      scope.bar = 'bar';
      scope.$apply(); // or deps.step()
      la(deps.element.html() === 'bar');
    });

Testing directive without ng-describe

function setupElement(elementHtml) {
    var scope = dependencies.$rootScope.$new();
    angular.extend(scope, 
      angular.copy(options.parentScope));
    var element = angular.element(elementHtml);
    var compiled = dependencies.$compile(element);
    compiled(scope);
    dependencies.$rootScope.$digest();
    dependencies.element = element;
    dependencies.parentScope = scope;
}
if (options.element) {
  beforeEach(setupElement.bind(null, options.element));
}

@bahmutov Kensho

Test directive: custom setup

// studyFlags directive makes HTTP call on init
ngDescribe({
  exposeApi: true,
  inject: '$httpBackend',
  tests: function (deps, describeApi) {
    











  });
});
    beforeEach(function () {
      deps.$httpBackend
        .expectGET('/api/foo/bar').respond(500);
    });
    beforeEach(function () {
      // now create an element ourselves
      describeApi.setupElement('<study-flags />');
    });
    it('created an element', function () {
      la(check.has(deps.element));
    });

Mock (fake) $http responses

ngDescribe({
  http: {
    get: {
      '/some/url': 42,
      '/some/other/url': [500, 'something went wrong']
    },
    post: {
      // you can use custom functions too
      '/some/post/url': function (method, url, data, headers) {
        return [200, 'ok'];
      }
    }
  },
  inject: '$http',
  tests: function (deps) {








  }
});

Shorthand notation for $httpBackend

    it('has meaning', function (done) {
        $http.get('/some/url').success(function (value) {
            expect(value).toEqual(42);
            done();
        });
        deps.step();
    });

@bahmutov Kensho

Mock (fake) everything

angular.module('User', [])
  .value('greeting', function greeting() {
    return 'Hello';
  })
  .value('username', function username($http) {
    return $http.get('/user/name');
  });

Service with 1 http GET and one local method

@bahmutov Kensho

Mock everything

angular.module('GreetUser', ['User'])
  .value('helloUser', function (greeting, username) {
    return username()
      .then(function (name) {
        return greeting() + ' ' + name + '!';
      });
  });

Use the service with "mixed" calls 

Server

User

GreetUser

GET

@bahmutov Kensho

Mock everything: test

ngDescribe({
  module: 'GreetUser',
  inject: ['helloUser', '$rootScope'],
  tests: function (deps) {
    it('makes http request', function (done) {
      deps.helloUser()
        .then(function (value) {
          // hmm
        })
        .finally(done);
      deps.$rootScope.$apply();
    });
  }
});
ட default tests 
  ட ✘ makes http request FAILED
	Error: Unexpected request: GET /user/name
	No more request expected

Server

User

GreetUser

Test

GET

What should we mock?

ngDescribe({
  module: 'GreetUser',
  inject: ['helloUser', '$rootScope'],
  http: {
    get: {
      '/user/name': 'World'
    }
  },
  tests: function (deps) {
    it('makes http request', function (done) {
      ...
    });
  }
});

BAD!

Server

User

GreetUser

Test

GET

@bahmutov Kensho

Mock 1st level deps

angular.module('GreetUser', ['User'])
  .value('helloUser', function (greeting, username) {
    return username()
      .then(function (name) {
        return greeting() + ' ' + name + '!';
      });
  });

Leave unchanged

Mock

@bahmutov Kensho

Mock 1st level deps test

ngDescribe({
  module: 'GreetUser',
  inject: ['helloUser', '$rootScope'],
  mock: {
    User: {
      username: function ($q) {
        return $q.when('Test');
      }
    }
  },
  tests: function (deps) {
    it('does not make http requests', function (done) {
      deps.helloUser()
        .then(function (value) {
          la(value === 'Hello Test!');
        })
        .finally(done);
      deps.$rootScope.$apply();
    });
  }
});

replaces User.username with this code for testing

Mock for the target

ngDescribe({
  module: 'GreetUser',
  inject: ['helloUser', '$rootScope'],
  mock: {
    GreetUser: {
      username: function ($q) {
        return $q.when('Test');
      }
    }
  },
angular.module('GreetUser', ['User'])
  .value('helloUser', function (greeting, username) {
    return username()
      .then(function (name) {
        return greeting() + ' ' + name + '!';
      });
  });

What about spies?

Yes, both Jasmine spies and sinon.js are working fine

@bahmutov Kensho

Bonus: user-friendly API

ngDescribe(optionsObject);
// single function taking 
// a single options object

@bahmutov Kensho

User-friendly API (aliases)

ngDescribe({
  module: 'A'
});
// plural
ngDescribe({
  module: ['A', 'B']
});
// and
ngDescribe({
  modules: ['A', 'B']
});

@bahmutov Kensho

Bonus: user-friendly API

ngDescribe({
  name: 'A',
  module: 'A',
  tests: function (deps) {
    ...
  });
})({
  name: 'A with B',
  modules: ['A', 'B'],
  tests: function (deps) {
    ...
  });
});

@bahmutov Kensho

Bonus: user-friendly API

ngDescribe({
  name: 'A',
  module: 'A',
  tests: function (deps) {
    ...
  });
})({
  name: 'A with B',
  modules: ['A', 'B'],
  tests: function (deps) {
    ...
  });
});
})({

@bahmutov Kensho

Butterfly: chained calls with options objects

ngDescribe({
  ...
})({
  ...
});
})({

@bahmutov Kensho

Skip, only, verbose

Skip a suite of tests

ngDescribe({
  name: 'not ready yet',
  skip: true,
  tests: function (deps) { ... }
});

@bahmutov Kensho

Skip, only, verbose

Focus on a suite of tests

ngDescribe({
  name: 'just this feature',
  only: true,
  tests: function (deps) { ... }
});

@bahmutov Kensho

Skip, only, verbose

Skip + only

ngDescribe({
  name: 'logical contradiction',
  only: true,
  skip: true,
  tests: function (deps) { ... }
});
// ERROR!

@bahmutov Kensho

Skip, only, verbose

If something does not work as expected

ngDescribe({
  name: 'let us find the problem',
  only: true,
  verbose: true,
  tests: function (deps) { ... }
});
// prints messages on suite setup, etc.

@bahmutov Kensho

Bonus: helpful assertion messages

it('does something', function () {
  ...
  expect(foo).toEqual('bar');
});
test "does something" failed
Error:

@bahmutov Kensho

Bonus: helpful assertion messages

it('does something', function () {
  ...
  expect(foo).toEqual('bar', 'expected foo to equal "bar"');
});
test "does something" failed
Error: expected foo to equal "bar"

What is the value of "foo"?

@bahmutov Kensho

Bonus: helpful assertion messages

it('does something', function () {
  ...
  expect(foo).toEqual('bar', 
    'expected foo ' + foo + ' to equal "bar"');
});
test "does something" failed
Error: expected foo something to equal "bar"

hmm, boilerplate code again

@bahmutov Kensho

helpDescribe(function () {
  ngDescribe({
    name: 'example',
    tests: function (deps) {
      it('does something', function () {
        ...
        la(deps.foo === 'bar');
      });
    }
  });
});
test "does something" failed
Error: condition [deps.foo === 'bar'] deps: { foo: ... }

lazy-ass-helpful works by on the fly rewriting test function's code

@bahmutov Kensho

If you have existing tests - keep them, rewrite using ng-describe whenever a change is needed

Q: should we convert the existing unit tests to ng-describe?

@bahmutov Kensho

No

Q: can it test Angular2 code?

@bahmutov Kensho

Open source software - open an issue, discuss, submit a pull request

Q: can ng-describe do X?

100% code coverage, 4x LOC of tests, 75 closed issues.

@bahmutov Kensho

CI tests ng-describe against Angular 1.2 - 1.5

using PhantomJs

on Node 0.12 - 5

We also monitor the code quality using Codacy

@bahmutov Kensho

Summary

Write fewer unit tests - target them better

Spend less time writing unit tests - use helper library like ng-describe

@bahmutov Kensho

Realization

Just because a large company (Google) makes a great MVC framework  does not mean YOU cannot write a great complementary library

@bahmutov Kensho

Realization

OSS contributions helped us find and fix the hardest bugs

@bahmutov Kensho

Thank you

Boston / NYC

https://kensho.com/#/careers Python, JS, data, machine learning

@bahmutov Kensho

Unit testing AngularJS

Gleb Bahmutov       Kensho

slides.com/bahmutov/testing-ng-confoo

github.com/kensho/ng-describe

or just Google "ng-describe"

Last slide with links

Take a picture!

@bahmutov Kensho

Unit testing AngularJS - ConFoo.ca

By Gleb Bahmutov

Unit testing AngularJS - ConFoo.ca

Testing Angular code requires too much boilerplate. In this presentation I will show ng-describe - a BDD helper that removes all extra code and allows to start unit testing in seconds.

  • 2,079

More from Gleb Bahmutov