21
votes

I am using jasmine to unit test an angularjs controller that sets a variable on the scope to the result of calling a service method that returns a promise object:

var MyController = function($scope, service) {
    $scope.myVar = service.getStuff();
}

inside the service:

function getStuff() {
    return $http.get( 'api/stuff' ).then( function ( httpResult ) {
        return httpResult.data;
    } );
}

This works fine in the context of my angularjs application, but does not work in the jasmine unit test. I have confirmed that the "then" callback is executing in the unit test, but the $scope.myVar promise never gets set to the return value of the callback.

My unit test:

describe( 'My Controller', function () {
  var scope;
  var serviceMock;
  var controller;
  var httpBackend;

  beforeEach( inject( function ( $rootScope, $controller, $httpBackend, $http ) {
    scope = $rootScope.$new();
    httpBackend = $httpBackend;
    serviceMock = {
      stuffArray: [{
        FirstName: "Robby"
      }],

      getStuff: function () {
        return $http.get( 'api/stuff' ).then( function ( httpResult ) {
          return httpResult.data;
        } );
      }
    };
    $httpBackend.whenGET( 'api/stuff' ).respond( serviceMock.stuffArray );
    controller = $controller( MyController, {
      $scope: scope,
      service: serviceMock
    } );
  } ) );

  it( 'should set myVar to the resolved promise value',
    function () {
      httpBackend.flush();
      scope.$root.$digest();
      expect( scope.myVar[0].FirstName ).toEqual( "Robby" );
    } );
} );

Also, if I change the controller to the following the unit test passes:

var MyController = function($scope, service) {
    service.getStuff().then(function(result) {
        $scope.myVar = result;
    });
}

Why is the promise callback result value not being propagated to $scope.myVar in the unit test? See the following jsfiddle for full working code http://jsfiddle.net/s7PGg/5/

3

3 Answers

21
votes

I guess that the key to this "mystery" is the fact that AngularJS will automatically resolve promises (and render results) if those used in an interpolation directive in a template. What I mean is that given this controller:

MyCtrl = function($scope, $http) {
  $scope.promise = $http.get('myurl', {..});
}

and the template:

<span>{{promise}}</span>

AngularJS, upon $http call completion, will "see" that a promise was resolved and will re-render template with the resolved results. This is what is vaguely mentioned in the $q documentation:

$q promises are recognized by the templating engine in angular, which means that in templates you can treat promises attached to a scope as if they were the resulting values.

The code where this magic happens can be seen here.

BUT, this "magic" happens only when there is a template ($parse service, to be more precise) at play. In your unit test there is no template involved so promise resolution is not propagated automatically.

Now, I must say that this automatic resolution / result propagation is very convenient but might be confusing, as we can see from this question. This is why I prefer to explicitly propagate resolution results as you did:

var MyController = function($scope, service) {
    service.getStuff().then(function(result) {
        $scope.myVar = result;
    });
}
8
votes

I had a similar problem and left my controller assigning $scope.myVar directly to the promise. Then in the test, I chained on another promise that asserts the expected value of the promise when it gets resolved. I used a helper method like this:

var expectPromisedValue = function(promise, expectedValue) {
  promise.then(function(resolvedValue) {
    expect(resolvedValue).toEqual(expectedValue);
  });
}

Note that depending on the ordering of when you call expectPromisedValue and when the promise is resolved by your code under test, you may need to manually trigger a final digest cycle to run in order to get these then() methods to fire - without it your test may pass regardless of whether the resolvedValue equals the expectedValue or not.

To be safe, put the trigger in an afterEach() call so you don't have to remember it for every test:

afterEach(inject(function($rootScope) {
  $rootScope.$apply();
}));
3
votes

@pkozlowski.opensource answered the why (THANK YOU!), but not how to get around it in testing.

The solution I just arrived at is to test that HTTP is getting called in the service, and then spy on the service methods in the controller tests and return actual values instead of promises.

Suppose we have a User service that talks to our server:

var services = angular.module('app.services', []);

services.factory('User', function ($q, $http) {

  function GET(path) {
    var defer = $q.defer();
    $http.get(path).success(function (data) {
      defer.resolve(data);
    }
    return defer.promise;
  }

  return {
    get: function (handle) {
      return GET('/api/' + handle);    // RETURNS A PROMISE
    },

    // ...

  };
});

Testing that service, we don't care what happens to the returned values, only that the HTTP calls were made correctly.

describe 'User service', ->
  User = undefined
  $httpBackend = undefined

  beforeEach module 'app.services'

  beforeEach inject ($injector) ->
    User = $injector.get 'User'
    $httpBackend = $injector.get '$httpBackend'

  afterEach ->
    $httpBackend.verifyNoOutstandingExpectation()
    $httpBackend.verifyNoOutstandingRequest()          

  it 'should get a user', ->
    $httpBackend.expectGET('/api/alice').respond { handle: 'alice' }
    User.get 'alice'
    $httpBackend.flush()    

Now in our controller tests, there's no need to worry about HTTP. We only want to see that the User service is being put to work.

angular.module('app.controllers')
  .controller('UserCtrl', function ($scope, $routeParams, User) {
    $scope.user = User.get($routeParams.handle);
  }); 

To test this, we spy on the User service.

describe 'UserCtrl', () ->

  User = undefined
  scope = undefined
  user = { handle: 'charlie', name: 'Charlie', email: '[email protected]' }

  beforeEach module 'app.controllers'

  beforeEach inject ($injector) ->
    # Spy on the user service
    User = $injector.get 'User'
    spyOn(User, 'get').andCallFake -> user

    # Other service dependencies
    $controller = $injector.get '$controller'
    $routeParams = $injector.get '$routeParams'
    $rootScope = $injector.get '$rootScope'
    scope = $rootScope.$new();

    # Set up the controller
    $routeParams.handle = user.handle
    UserCtrl = $controller 'UserCtrl', $scope: scope

  it 'should get the user by :handle', ->
    expect(User.get).toHaveBeenCalledWith 'charlie'
    expect(scope.user.handle).toBe 'charlie';

No need to resolve the promises. Hope this helps.