Proper unit-testing of Angular JS 1.X 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.
A bit of history
Writing tests represent a very important part of software development activity. It is hard to imagine refactoring any piece of logic written by somebody else without having it covered by at least some unit tests. Testing also becomes much more important when you are using dynamically typed languages such as Javascript, even if the project is small and you written everything by yourself.
The concept and value of testing should be obvious to all of those fortunate developers who have found themselves in situation where they were building any non-trivial Angular JS application.
The standard way of testing Angular JS application is using Karma test runner together with Jasmine test framework. Karma then runs tests written with help of Jasmine API in a browser. You can choose your preferred browser while developing locally, but the proper "fun" starts with a team of developers and one or more continuous integration environments. Usually the browser of choice becomes PhantomJS which tends to demonstrate rather quirky behavior with it's two separated context (node.js and browser) and the dreaded timeouts.
Anatomy of a standard Jasmine test
Let's check an example of a simple test which was written with help of above mentioned technologies.
describe('TodoService', function() {
var TodoService, InitialTodosMock;
// Instantiate Angular JS context
beforeEach(module("app"));
// Register mocks in Angular JS context
beforeEach(module(function($provide) {
InitialTodosMock = [
{
label: 'Test todo',
done: false
}
];
$provide.value('initialTodos', InitialTodosMock);
}));
// Get instance of TodoService with mocked dependencies from Angular JS context
beforeEach(inject(function (_TodoService_) {
TodoService = _TodoService_;
}));
// Oh, ... do the actual testing !!!
it('should have initial todo', function() {
expect(TodoService.todos.length).toBe(1);
expect(TodoService.todos[0].label).toBe('Test todo');
expect(TodoService.todos[0].done).toBe(false);
});
// ... it should add, toggle and remove done todos
});
As you can see, there is quite a lot of Angular JS API being used, mainly during the initialization of test context. But wait, it gets even better in case of controllers...
describe('TodoController', function() {
var scope, $rootScope, $controller;
// Instantiate Angular JS context
beforeEach(module('app'));
// Register mocks in Angular JS context
// (sometimes not necessary, we can use real services too, but the Angular context grows...)
beforeEach(module(function($provide) {
var TodoServiceMock = {
todos: [],
addTodo: function() {
// ...
},
toggleTodo: function() {
// ...
},
removeDoneTodost: function() {
// ...
}
};
$provide.value('TodoService', TodoServiceMock);
}));
// Get instance of TodoController, you know, create new $scope from $rootScope by yourself and stuff...
// It is possible to not use $scope when using 'controllerAs' syntax,
// but you still have to use at least $controller to get the refference to controller itself
beforeEach(inject(function(_$rootScope_, _$controller_, _TodoService_){
$controller = _$controller_;
$rootScope = _$rootScope_;
scope = $rootScope.$new();
$controller('TodoController', {
$scope: scope,
TodoService: _TodoService_
});
}));
// Oh, ... do the actual testing !!!
it('should have initial todos', function() {
expect(scope.todos.length).toBe(1);
});
// ... it should add, toggle and remove done todos
});
By the way, how many times did you called $rootScope.$digest(); in your tests? You know to test anything with promises...
So what's the problem?
Hmm, let me start with ...
- Angular context module('app') must be instantiated 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.
- Angular and all other used libraries must be included during testing so that it is even possible to instantiate Angular context (check out this example karma.conf file in official Angular Github repository, mainly the files property)
- Angular context can grow quite large so that it's creation will consume considerable amount of time for every test file.
- Karma exclusion syntax doesn't follow standard node glob pattern which can make you go crazy when you try to solve timeout errors caused by insufficient memory on PhantomJS by splitting test execution into multiple batches, while supporting dev mode single test execution (karma uses extra exclude property instead of supporting standard "!")
I could definitively find more things that really grind my gears about Karma, Jasmine combo but the most important thing and the root of many other problems is described in the fist point of this list.
What is Angular context and why do we have to instantiate it for every test?
When I am writing about Angular context, what I really want to describe is initialized Angular JS dependency injection mechanism. I suppose that the original solution was somewhere along the lines of working with available technology and choosing lesser evil during the development of early Angular JS incarnation. The key points were:
- No readily available or standardized solution for implementing modules in Javascript language itself
- Need to support larger applications with respective large code bases
These two points led to implementation of original Angular JS dependency injection mechanism which can be summarized as:
Angular JS dependency mechanism works by registering everything on global angular object by using exposed API like module, controller, factory...
This works really great in environment dominated by projects consisting of multiple concatenated Javascript files that access the same global object which will be available at run-time (like angular). It made perfect sense at the time but with advent of node.js with it's commonjs module syntax, require.js for AMD, Browserify, Webpack, ... all the way up to the official ES6 modules, it became less relevant and cumbersome to work with.
What does it mean for the tests?
To summarize, the many times mentioned Angular context is nothing else than initialized dependency injection mechanism. It can be quite small for a particular test if you split your app into multiple angular modules but never the less you still need Angular DI to at least get the access to the functionality you want to test.
Enter (ES6) module era
This particular approach is in fact achievable by 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
To put it bluntly ...
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);
});
it('should toggle todo', function () {
service.addTodo('Finish example project');
assert.equal(service.todos[0].done, false);
service.toggleTodo('Finish example project');
assert.equal(service.todos[0].done, true);
service.toggleTodo('Finish example project');
assert.equal(service.todos[0].done, false);
});
it('should remove done todos', function () {
service.addTodo('Todo1');
service.addTodo('Todo2');
service.addTodo('Todo3');
assert.equal(service.todos.length, 3);
service.toggleTodo('Todo1');
service.removeDoneTodos();
assert.equal(service.todos.length, 2);
});
});
Isn't that beautiful ? and cleaner, simpler, more concise, effective...
So what have we done there?
As you can 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.
Dependencies (if needed) are passed explicitly as a parameter of function
Let's continue with the test for controller...
import { assert } from 'chai';
import SomeComponent from './some-component';
let component;
describe('some-component', function() {
beforeEach(function() {
component = new SomeComponent();
});
it('should start with counter value 20', function () {
assert.equal(component.counter, 20);
});
it('should accept initial counter value as dependency', function () {
component = new SomeComponent(30);
assert.equal(component.counter, 30);
});
it('should increment counter value after increment is called', function () {
assert.equal(component.counter, 20);
component.increment();
assert.equal(component.counter, 21);
});
});
Again if it has some dependencies, just pass them as a parameters to the function.
How does it work ?
We are using Mocha test runner together with chai assertion library. The tests are importing just the tested units (like controller or service function) and then the tests are simply executed. It's fast! No browser, no Angular context, simple mocking by hand or using some of the available libraries like Sinonjs. So the testing is easy but how do we integrate our functionality into Angular? The basic gist of it is to separate the functionality from the registering it into angular context into two separate files (modules). Service file then can look something like this:
export default function TodoService(initialTodos) {
// implementation ...
}
And now it's time to register it into Angular context...
import angular from 'angular';
import { DEFAULT_TODOS } from './feature-b.constants.js';
import TodoService from './services/todo.service';
export default angular
.module('main.app.feature-b', [])
.factory('TodoService', TodoService)
.constant('initialTodos', DEFAULT_TODOS)
.name;
We imported and registered both service and its dependency, the initialTodos constant. You can apply the same approach for all other Angular JS constructs like directives, controllers, ...
Use the modules!
It's good for you...
(ES6) Modules changed everything. Your controllers / classses / services / whatever are now just that. An easily testable function or class exported from a stand-alone module (file)
- Tests are simpler and faster
- Separation of implementation from Angular nudges you towards using less of soon to be deprecated Angular JS 1.X API like $scope, $watch, $on, ...
- Mocha has quite good support for asynchronous testing with promises (like beforeEach will wait for resolution of promise before further execution if it was returned at the end of it's function body)
- No implicitly available dependencies (from initialized Angular JS context) will make all mocking explicit which is more in direction of proper unit testing (or you can just require the original dependency's implementation in the test and explicitly inject it into tested service)
- As for Angular 2.0, dependencies will be declared using Angular 2.0 @notations (which are implemented using ES7 decorators) so if you just import component's class without Angular 2.0 context everything would be the same because the decorators will be inert without Angular 2.0 being present. Then again you will instantiate your classes and pass you dependencies explicitly, or the Angular guys will come with some much better solution
Integration & E2E testing ...
While I haven't used this pattern in any bigger project yet, it looks really similar to what we have done with the node.js modules in ongoing project. Basically integration testing would be executed by importing service with it's whole dependency tree or even with Karma / Jasmine combo. They are quite good in initializing of Angular JS context, aren't they? As for E2E tests, no change there... Protractor or Webdriver or whatever you like...