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

In this 3 part series, I will walk you through the setup and the way we unit test our AngularJS + UI-Router application at EV-Box. In short, each part will focus on testing of:

  1. UI-Router state configuration and transitions
  2. AngularJS controllers
  3. AngularJS services

Now you might be wondering — what about AngularJS directive/component testing? If you follow AngularJS best practices, you are probably aware of a recommendation to keep business logic out of directives/components, delegating it to services. At the same time, the focus of a directive or a component controller should be on DOM manipulation and user event handling only. Yes, you have read it right — since AngularJS v1.5 DOM manipulation can and should be done in component controllers, completely eliminating the need to use the directive in most cases and making a future upgrade to AngularJS v2 smoother. See the official docs comparing them both — https://docs.angularjs.org/guide/component#comparison-between-directive-definition-and-component-definition.

So, directives and components out of the way, we are left with controllers and services and, if you manage to keep your controllers slim and clean (we at EV-Box do), most of the heavy lifting is done by services that are nothing else but pure JavaScript objects, sprinkled with some async http requests here and there.


Before we begin with UI-Router configuration testing, I want to briefly outline our current setup which is not very different from any other AngularJS based app in a sense that we also use Karma test runner and Jasmine assertion framework, but one thing that works very well for us — and I still don’t see many examples of it in the wild — is file and folder structure. We modularise our code, group related files by feature and separate out each AngularJS entity into its own file so, for instance, a typical login module might consist of a login.module.js, login.route.js, login.component.js, login.controller.js, login.html, login.scss and related Jasmine spec files: login.route.spec.js and login.controller.spec.js. This allows us to test each file in isolation, mocking any dependencies if needed. Note that we keep Jasmine spec files next to their sources, making them easy to locate and maintain.

Login module where every AngularJS entity is in its own file

Testing UI-Router state configuration and transitions

Unlike AngularJS $route service, UI-Router deals with states and not routes, but otherwise, the concepts of testing them are very similar. Below we have a login state configuration with / url which means it is our landing page at https://evbox-app/#/. Then we have some custom data properties and a view with a template.

However, anon.landing.login is a nested state and before we can get to it, parent state has to be resolved. To give some more context, we have structured all application states using a simple convention: states that do not require authentication start with anon. prefix, states that do require authentication start with user. prefix. Thus, our anon.landing.login state is a child of anon.landing which, in turn, is a child of anon state. At the top level of the application, we have 2 abstract state configurations: user and anon and all other states span from them as a tree-like structure, only from top to bottom. Let’s take a look at the anon.landing state configuration below.

As you can see, the anon.landing is an abstract state with url set to empty string because we don’t want it to appear in the path. Then we have a couple of resolves: one to get a list of languages for localisation and another one to get a user profile. Depending on whether the user is logged in or not, we either redirect to a home page using $state.go('user.home', null, {location: 'replace'}); or simply catch the error and log it to the console because we don’t want it to block the resolution of user resolve (the actual error from session service is handled elsewhere utilising UI-Router’s $stateChangeError event).

Testing login route and state transition means that we are indirectly testing its parent anon.landing, as you can see in the code snippet bellow.

The first beforeEach is global within the first describe block and, as the name implies, is run before each test case. This is the place to add our test preparation logic that should be reused every time Jasmine’s it case is invoked.

beforeEach(function () {
module('evbox', 'evbox.templates', mockServices);
inject(services);
setUp();
});

We first register evbox and evbox.templates modules where evbox is our main app module and evbox.templates contains all app templates so that we don’t need to repeat ourselves and put component specific templates into $templateCache within each test suite. The last argument for module is our mockServices function. Then we inject other necessary services with inject(services) and finally, complete the setup by mocking http calls using $httpBackend in setUp().

Remember, in this part, we are testing state configuration and state transition, not services, so we need to mock any dependencies in state configuration. For languageService and sessionService we create mocks in a form of Jasmine spy objects .

var languageServiceMock = jasmine.createSpyObj('languageService', [
'getTenantLanguages'
]);
var sessionServiceMock = jasmine.createSpyObj('sessionService', [
'getUserPromise'
]);

We then mock the real services replacing them with Jasmine spies, in order to test if a relevant service method was called from within a state config resolve.

function mockServices($provide) {
$provide.factory('languageService', function () {
return languageServiceMock;
});

$provide.factory('sessionService', function () {
return sessionServiceMock;
});
}

Services injection is pretty straight forward as you can see below.

function services($injector) {
$q = $injector.get('$q');
$state = $injector.get('$state');
$httpBackend = $injector.get('$httpBackend');
$templateCache = $injector.get('$templateCache');
$location = $injector.get('$location');
}

It is a good practice to group all test cases by putting them inside a top level describe block which, in our case, is describe(‘when navigating to `/`’, … where / denotes our login route.

The next thing you’ll notice is a helper function goTo(). The purpose of it is to abstract commonly used steps, like updating the url and triggering a new $digest cycle via $httpBackend.flush(). Since we are testing a state configuration using mocked services, we need to settle all promises resulting from service http calls. Hence, in the following beforeEach we make sure that languageService.getTenantLanguages() promise is resolved with an empty array. The simplest way to fulfil a promise in a mocked service replaced by a Jasmine spy object is by using <methodName>.and.returnValue($q.resolve(<anyValue>) construct. Likewise, if we need to simulate promise rejection, we can use <methodName>.and.returnValue($q.reject(<error>). Check it out.

beforeEach(function () {
languageServiceMock.getTenantLanguages.and.returnValue($q.resolve([]);
});

Now we have to test 2 possible cases: one where a user is already logged in and another one where the user is not logged in.

To simulate unauthenticated user, we simply reject a promise resulting from a session service method call. Rejected promise is caught and does not propagate up the chain which means that a user resolve is fulfilled in anon.landing state and state transition to anon.landing.login takes place. See the complete test bellow were we assert the calls to service methods and make sure that$state.current.name is indeed anon.landing.login.

describe('and user is not logged in', function () {
it('transitions to the login state', function () {
sessionServiceMock.getUserPromise.and.returnValue($q.reject({}));

goTo('/');
  expect(languageServiceMock.getTenantLanguages).toHaveBeenCalled();
expect(sessionServiceMock.getUserPromise).toHaveBeenCalled();
expect($state.current.name).toBe('anon.landing.login');
});
});

In case were the user is already authenticated, we want to redirect to our home page. The only difference from the previous test case is that we fulfil a session service promise which means that an onFulfilled() handler is invoked and a redirect takes place via $state.go(‘user.home’, null, {location: ‘replace’}) in anon.landing state.

describe('and user is logged in', function () {
it('redirects to the home state', function () {

sessionServiceMock.getUserPromise.and.returnValue($q.resolve({}));

goTo('/');

expect(languageServiceMock.getTenantLanguages).toHaveBeenCalled();
expect(sessionServiceMock.getUserPromise).toHaveBeenCalled();
expect($state.current.name).toBe('user.home');
});
});

This completes the first part in a 3 part series. If you liked it, recommend it so that other people could see and read it here on Medium.

Stay tuned for part 2 where we will look at testing AngularJS component controllers.