简体   繁体   中英

AngularJS Promise Callback Not Trigged in JasmineJS Test

Problem Intro

I'm trying to unit test an AngularJS service that wraps the Facebook JavaScript SDK FB object; however, the test isn't working, and I haven't been able to figure out why. Also, the service code does work when I run it in a browser instead of a JasmineJS unit test, run with Karma test runner .

I'm testing an asynchronous method using Angular promises via the $q object. I have the tests set up to run asynchronously using the Jasmine 1.3.1 async testing methods , but the waitsFor() function never returns true (see test code below), it just times-out after 5 seconds . (Karma doesn't ship with the Jasmine 2.0 async testing API yet).

I think it might be because the then() method of the promise is never triggered (I've got a console.log() set up to show that), even though I'm calling $scope.$apply() when the asynchronous method returns, to let Angular know that it should run a digest cycle and trigger the then() callback...but I could be wrong.

This is the error output that comes from running the test:

Chrome 32.0.1700 (Mac OS X 10.9.1) service Facebook should return false
  if user is not logged into Facebook FAILED
  timeout: timed out after 5000 msec waiting for something to happen
Chrome 32.0.1700 (Mac OS X 10.9.1):
  Executed 6 of 6 (1 FAILED) (5.722 secs / 5.574 secs)

The Code

This is my unit test for the service ( see inline comments that explain what I've found so far ):

'use strict';

describe('service', function () {
  beforeEach(module('app.services'));

  describe('Facebook', function () {
    it('should return false if user is not logged into Facebook', function () {
      // Provide a fake version of the Facebook JavaScript SDK `FB` object:
      module(function ($provide) {
        $provide.value('fbsdk', {
          getLoginStatus: function (callback) { return callback({}); },
          init: function () {}
        });
      });

      var done = false;
      var userLoggedIn = false;

      runs(function () {
        inject(function (Facebook, $rootScope) {
          Facebook.getUserLoginStatus($rootScope)
            // This `then()` callback never runs, even after I call
            // `$scope.$apply()` in the service :(
            .then(function (data) {
              console.log("Found data!");
              userLoggedIn = data;
            })
            .finally(function () {
              console.log("Setting `done`...");
              done = true;
            });
        });
      });

      // This just times-out after 5 seconds because `done` is never
      // updated to `true` in the `then()` method above :(
      waitsFor(function () {
        return done;
      });

      runs(function () {
        expect(userLoggedIn).toEqual(false);
      });

    }); // it()
  }); // Facebook spec
}); // Service module spec

And this is my Angular service that is being tested ( see inline comments that explain what I've found so far ):

'use strict';

angular.module('app.services', [])
  .value('fbsdk', window.FB)
  .factory('Facebook', ['fbsdk', '$q', function (FB, $q) {

    FB.init({
      appId: 'xxxxxxxxxxxxxxx',
      cookie: false,
      status: false,
      xfbml: false
    });

    function getUserLoginStatus ($scope) {
      var deferred = $q.defer();
      // This is where the deferred promise is resolved. Notice that I call
      // `$scope.$apply()` at the end to let Angular know to trigger the
      // `then()` callback in the caller of `getUserLoginStatus()`.
      FB.getLoginStatus(function (response) {
        if (response.authResponse) {
          deferred.resolve(true);
        } else {
          deferred.resolve(false)
        }
        $scope.$apply(); // <-- Tell Angular to trigger `then()`.
      });

      return deferred.promise;
    }

    return {
      getUserLoginStatus: getUserLoginStatus
    };
  }]);

Resources

Here is a list of other resources that I've already taken a look at to try to solve this problem.

Summary

Please note that I'm aware that there are already other Angular libraries out there for the Facebook JavaScript SDK , such as the following:

I'm not interested in using them right now, because I wanted to learn how to write an Angular service myself. So please keep answers restricted to helping me fix the problems in my code , instead of suggesting that I use someone else's .

So, with that being said, does anyone know why my test isn't working?

TL;DR

Call $rootScope.$digest() from your test code and it'll pass:

it('should return false if user is not logged into Facebook', function () {
  ...

  var userLoggedIn;

  inject(function (Facebook, $rootScope) {
    Facebook.getUserLoginStatus($rootScope).then(function (data) {
      console.log("Found data!");
      userLoggedIn = data;
    });

    $rootScope.$digest(); // <-- This will resolve the promise created above
    expect(userLoggedIn).toEqual(false);
  });
});

Plunker here .

Note: I removed run() and wait() calls because they're not needed here (no actual async calls being performed).

Long explanation

Here's what's happening: When you call getUserLoginStatus() , it internally runs FB.getLoginStatus() which in turn executes its callback immediately , as it should, since you've mocked it to do precisely that. But your $scope.$apply() call is within that callback, so it gets executed before the .then() statement in the test. And since then() creates a new promise, a new digest is required for that promise to get resolved.

I believe this problem doesn't happen in the browser because of one out of two reasons:

  1. FB.getLoginStatus() doesn't invoke its callback immediately so any then() calls run first; or
  2. Something else in the application triggers a new digest cycle.

So, to wrap it up, if you create a promise within a test, explicitly or not, you'll have to trigger a digest cycle at some point in order for that promise to get resolved.

    'use strict';

describe('service: Facebook', function () {
    var rootScope, fb;
    beforeEach(module('app.services'));
    // Inject $rootScope here...
    beforeEach(inject(function($rootScope, Facebook){
        rootScope = $rootScope;
        fb = Facebook;
    }));

    // And run your apply here
    afterEach(function(){
        rootScope.$apply();
    });

    it('should return false if user is not logged into Facebook', function () {
        // Provide a fake version of the Facebook JavaScript SDK `FB` object:
        module(function ($provide) {
            $provide.value('fbsdk', {
                getLoginStatus: function (callback) { return callback({}); },
                init: function () {}
            });
        });
        fb.getUserLoginStatus($rootScope).then(function (data) {
            console.log("Found data!");
            expect(data).toBeFalsy(); // user is not logged in
        });
    });
}); // Service module spec

This should do what you're looking for. By using the beforeEach to set up your rootScope and afterEach to run the apply, you're also making your test easily extendable so you can add a test for if the user is logged in.

From what I can see the problem of why your code isnt working is that you havent injected $scope. Michiels answer works cuz he injects $rootScope and call the digest cycle. However your $apply() is a higher level was of invoking the digest cycle so it will work as well... BUT! only if you inject it into the service itself.

But i think a service doesn't create a $scope child so you need to inject $rootScope itself - as far as I know only controllers allow you to inject $scope as its their job to create well a $scope. But this is a bit of speculation, I am not 100 percent sure about it. I would try however with $rootScope as you know the app has a $rootScope from the creation of ng-app.

'use strict';

angular.module('app.services', [])
  .value('fbsdk', window.FB)
  .factory('Facebook', ['fbsdk', '$q', '$rootScope' function (FB, $q, $rootScope) { //<---No $rootScope injection

    //If you want to use a child scope instead then --> var $scope = $rootScope.$new();
    // Otherwise just use $rootScope

    FB.init({
      appId: 'xxxxxxxxxxxxxxx',
      cookie: false,
      status: false,
      xfbml: false
    });

    function getUserLoginStatus ($scope) { //<--- Use of scope here, but use $rootScope instead
      var deferred = $q.defer();
      // This is where the deferred promise is resolved. Notice that I call
      // `$scope.$apply()` at the end to let Angular know to trigger the
      // `then()` callback in the caller of `getUserLoginStatus()`.
      FB.getLoginStatus(function (response) {
        if (response.authResponse) {
          deferred.resolve(true);
        } else {
          deferred.resolve(false)
        }
        $scope.$apply(); // <-- Tell Angular to trigger `then()`. USE $rootScope instead!
      });

      return deferred.promise;
    }

    return {
      getUserLoginStatus: getUserLoginStatus
    };
  }]);

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM