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?