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.
What did you include in your beforeEach block?