AngularJS and UI-Router testing — the right way, Part 2

Audrius Jakumavičius
Everon Engineering
Published in
6 min readDec 13, 2016

In part 1 of the 3 part series, we looked at unit testing UI-Router state configuration and transitions — https://medium.com/evbinary/angularjs-and-ui-router-testing-the-right-way-part-1-c165c4565549#.vkmitw1gu. This time we will be testing AngularJS controller and, to be precise, component’s controller.

Starting with v1.5, AngularJS is shipping with a new building block — component that, in most cases, can be a direct replacement for a directive. If you are not using components to structure your app yet, you definitely should. Here is a good intro to get started: https://toddmotto.com/exploring-the-angular-1-5-component-method/. A typical component has a controller, template and optional data bindings passed to the controller. Data binding in the component can be achieved using one of the following expressions: = for two-way binding, <for one-way binding, @ for string values and & for callback functions.

Two-way binding was a very cool feature in the early days of AngularJS and we all loved it, but it’s time to move on and admit that unidirectional data flow is easier to reason about, gives you more control, results in in a more testable and cleaner architecture overall. AngularJS had followed in the footsteps of React and introduced one-way binding in components and directives, so I strongly recommend using <instead of = where applicable.

For ease of maintenance and testability, rather than having it all in one file, we create a separate file for component’s controller. Then the component looks very slim and does not require any testing at all.

angular.module('evbox.cards')
.component('evboxCards', {
controller: 'CardsController',
templateUrl: 'modules/cards/cards.html',
bindings: {
cards: '<',
profile: '<'
}
});

As you can see above, we have all the building blocks of the component: controller CardsController, templateUrl modules/cards/cards.html and two unidirectional data bindings.

Now let’s take a look at CardsController below.

From a first sight, it seems similar to a standard controller — we just reference it by name in our component’s declaration. The CardsController.$inject = [‘cardService’, ‘permissionService’, ‘utils’]; line is necessary to avoid dependency injection issues when a code is minified. For convenience and to be consistent with the default name that AngularJS controller gets in the view, we assign this to $ctrl variable.

The next thing you’ll notice is $ctrl.$onInit function. It’s one of the lifecycle hooks available in component controllers and is invoked after controller initialises but before the view is compiled. It’s important to keep in mind that lifecycle hooks are only available in the component’s controller, but not ng-controller and that is another advantage of using components. Lifecycle hooks are built into AngularJS V2 and were back-ported to V1.5.3.

We always do initial setup within $ctrl.$onInit because this way we can be sure that bound properties are assigned to $ctrl before we use them. In fact, starting with AngularJS v1.6 release, bound and local properties are no longer assigned to the controller and are not available in controller’s public methods if you don’t use $onInit (https://github.com/angular/angular.js/commit/bcd0d4d896d0dfdd988ff4f849c1d40366125858). Also, an added benefit of using $onInit is that it helps with controller’s code organisation and testability, as you will see in a bit.

We have a few more methods: $ctrl.setMode which toggles list/grid view mode, $ctrl.orderBy for sorting items in list mode and $ctrl.selectOrderBy for sorting in grid mode. The later two are very similar and call the same cardService methods.

Done with the source, let’s see what the controller’s spec file looks like.

At the very top, we define some variables and create a cardServiceMock. We don’t want to test cardService methods so the best thing to do is replace them with Jasmine’s spies. This way we can invoke those methods when needed and return mock data in tests. Our first beforeEach block does a few things: instantiates evbox module, injects services and instantiates controller.

beforeEach(function () {
module('evbox');
inject(services);
instantiateController();
});

services function is pretty straight forward — it injects cardsMock service and $controller, the service that is responsible for instantiating controllers. cardsMock is a JSON file with some mocked data for unit test purpose. It also becomes very handy while developing without a real back-end — we can inject this mock into a service and mock HTTP calls to the back-end.

angular.module('evbox.cards')
.value('cardsMock', {
cards: [
{
id: 'aaa',
reference: 'my card',
holder: 'driver@tesla.com',
contractId: 'NL-EVB-111111'
},
{
id: 'bbb',
reference: 'gold card',
holder: 'driver@evbox.com',
contractId: 'NL-EVB-222222'
},
{
id: 'ccc',
reference: 'other card',
holder: 'driver@evbox.com',
contractId: 'NL-EVB-333333'
}
]
});

The next thing we need to do is instantiate CardsController. The second argument to the $controller is injectable services object. You can leave it empty to use real services, but we have mocked cardService and replaced it with cardServiceMock. The third argument is for bound properties from component’s declaration. We have the following bindings in place:

bindings: {
cards: '<',
profile: '<'
}

When testing component’s controller, we can simply mock cards and profile bindings using an object as the third argument to the $controller.

controller = $controller('CardsController', {
cardService: cardServiceMock
}, {
cards: cardsMock.cards,
profile: {
permissions: ['CARD:CREATE']
}
});

Our first describe block begins with a beforeEach function where we set up a return value for a mocked cardService.getColumns() method and call controller.$onInit() to initialise CardsController.

In the first unit test, we assert initial controller’s state.

it('has default state', function () {
expect(controller.state).toEqual({
viewMode: 'list',
canRegisterCard: true
});
});

Then we check if the state correctly updates.

describe('when view mode is changed', function () {
it('updates the state', function () {
controller.setMode('grid');

expect(controller.state.viewMode).toBe('grid');
});
});

In describe(‘card reordering’ ... block we are going to test the re-ordering of cards in a list and grid modes. Again, we need to do some prep work in beforeEach function. Since the actual reordering is done in cardService and we have replaced it with cardServiceMock the only thing that’s needed here is to return some values from orderBy() method call. If you take a look at our controller’s $ctrl.orderBy method, you will see that on the very last line we assign returned value to $ctrl.cards.

$ctrl.cards = cardService.orderBy($ctrl.cards, column.property, column.isDesc);

Hence, we use Jasmine’s callFake method which replaces the real method and returns the same $ctrl.cards that we passed as the first argument to the service method. Remember, we are not interested in whether the sorting works correctly here — that would be a responsibility of cardService unit tests. All we care about is if the right service method was called with the right arguments.

cardServiceMock.orderBy.and.callFake(function (cards) {
return cards;
});

Another important thing to keep in mind when using Jasmine’s spies is to reset the calls to spy object’s methods between the tests to avoid false positive results. The right place for this is the afterEach function.

afterEach(function () {
cardServiceMock.orderBy.calls.reset();
});

Reordering can be done in one of the two ways on the UI. Cards can be reordered via list headers when in list view and via drop-down select when in grid view. We have to test both cases since there are some difference between implementations.

First, we will test reordering via list column headers.

it('reorders via column headers in list view', function () {
var column = {
property: 'holder',
isDesc: false
};

controller.orderBy(column);

expect(cardServiceMock.updateColumnState).toHaveBeenCalledWith(controller.columns, column);
expect(cardServiceMock.orderBy).toHaveBeenCalledWith(controller.cards, column.property, column.isDesc);
});

We call controller.orderBy(column) with a mocked column object and assert if a Jasmine spy on cardServiceMock.updateColumnState() has been called with correct arguments. Next, we assert if cardServiceMock.orderBy() has been called with the right arguments.

The test for reordering via a drop-down select is almost identical. We create a mocked option object and call controller.selectOrderBy(option), asserting if correct calls with the right arguments were made to cardServiceMock.updateColumnState() and cardServiceMock.orderBy().

it('reorders via drop-down in grid view', function () {
var option = {
property: 'reference',
isDesc: true
};

controller.selectOrderBy(option);

expect(cardServiceMock.updateColumnState).toHaveBeenCalledWith(controller.columns, {property: option.property, isDesc: !option.isDesc});
expect(cardServiceMock.orderBy).toHaveBeenCalledWith(controller.cards, option.property, option.isDesc);
});

As you can see, controller testing can be as simple as that if you manage to isolate different parts of your application and mock the dependencies. All of the business logic can be moved into a service leaving controller very slim and testable. It goes hand in hand with such principles as separation of concerns and single responsibility.

To summarise, here are some of the concepts and best practices when testing component’s controller:

  1. Always do your initialisation of controller’s state and local properties in $ctrl.$onInit().
  2. When mocking service methods, Jasmine’s spies are your life saviours. You can easily replace entire service with a Jasmine spy object using var cardServiceMock = jasmine.createSpyObj(‘cardService’, [‘getColumns’, ...]. Then set up the spy’s method to return any value you need at any point in your unit tests, like cardServiceMock.getColumns.and.returnValue(someValue). It also works well with promises when testing asynchronous service’s methods — cardServiceMock.getColumns.and.returnValue($q.resolve(someValue)) or cardServiceMock.getColumns.and.returnValue($q.reject(error)). You just need to call $httpBackend.flush() or $timeout.flush() to resolve the promise.
  3. For predictable results, don’t forget to reset calls to Jasmine’s spies between the tests using afterEach function: cardServiceMock.orderBy.calls.reset().

In next part we will see what it takes to test an AngularJS service. Stay tuned!

--

--

Audrius Jakumavičius
Everon Engineering

Senior front-end developer (freelance) at Royal Schiphol Group