Unit Testing Controller State Transitions in AngularJS with Jasmine

State transitions in AngularJS are surprisingly hard to unit test.

Assume the following scenario:

A user opens their app, and the app checks to see if they’ve paired to a Bluetooth device. If they have, the app sends them to their home screen. If they haven’t, the app sends them to the pairing screen.

Let’s create a test for it, assuming AngularJS as the framework:

The App

var app = angular.module('plunker', ['ui.router']);
app.config(function($stateProvider, $urlRouterProvider) {
  $stateProvider
  .state('home', {
    url:'/',
    controller: 'MainCtrl',
  })
  .state('pair', {
    url:'/pair',
    controller: 'PairCtrl'
  })
  $urlRouterProvider.otherwise('/');
});
app.controller('MainCtrl',['$scope', '$state', function($scope, $state) {
  $scope.name = 'World';
  $scope.isPaired = function(paired) {
    //in place of Service call; just use parameter.
    if (!paired) {
      $state.transitionTo('pair');
    }
    else {
      return paired; 
    }
  };
}]);

And the test:

it('should transition to pairing state if device is not paired',   function() {
  $scope.$apply();
  expect($state.current.name).toBe('home');
  $scope.isPaired(false);
  $scope.$apply();
  expect($state.current.name).toBe('pair');
})

This code works. And it works well. But what happens if you suddenly include `templateUrl`s in your routing code?

$stateProvider
.state('home', {
  url:'/',
  controller: 'MainCtrl',
  templateUrl: '/home.html'
})
  .state('pair', {
  url:'/pair',
  controller: 'PairCtrl',
  templateUrl: '/pair.html'
})

All of a sudden, the unit test breaks with the following error:

Error: Unexpected request: GET /home.html
No more request expected

What happened?

When the templateUrl parameter was included, ui-router suddenly decided it needed to make a request to that URL on $scope.$apply() (also on $scope.$digest()).

Because that URL doesn’t exist in our world (we are unit testing, after all), the test fails.

The way to “fix it” is to include a mock that will ignore requests by responding to them as if they were 200s:

it('should transition to pairing state if device is not paired', function() {
  $httpBackend.when('GET', '/home.html').respond(200);
  $scope.$apply();
  $httpBackend.flush();
  expect($state.current.name).toBe('home');

  $scope.isPaired(false);

  $httpBackend.when('GET', '/pair.html').respond(200);
  $scope.$apply();
  $httpBackend.flush();
  expect($state.current.name).toBe('pair');
})


The downside to this approach is your Unit tests now are much more brittle than they should be. Any time a route is changed/added to your workflow, you have to change these tests, even though you shouldn’t have to.

There is a state-mock.js that encourages mocking out the entire $state so the UI-Router doesn’t take over, but that’s also more plumbing code that shouldn’t exist.

In an ideal world, UI-Router would be patched to allow for easier unit testing.