问题
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.
Angular API Reference: $q
This explains how to use promises in Angular, as well as giving an example of how to unit-test code that uses promises (note the explanation of why
$scope.$apply()
needs to be called to trigger thethen()
callback).Jasmine Async Testing Examples
- Jasmine.Async: Making Asynchronous Testing With Jasmine Suck Less
Testing Asynchronous Javascript w/ Jasmine 2.0.0
These give examples of how to use the Jasmine 1.3.1 async methods to test objects implementing the Promise pattern. They're slightly different from the pattern I used in my own test, which is modeled after the example that comes directly from the Jasmine 1.3.1 async testing documentation.
StackOverflow Answers
- Promise callback not called in Angular JS
- Answer 1
- Answer 2
- angularjs - promise never resolved in controller
- AngularJS promises not firing when returned from a service
- Promise callback not called in Angular JS
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:
- angular-easyfb
- angular-facebook
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?
回答1:
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:
FB.getLoginStatus()
doesn't invoke its callback immediately so anythen()
calls run first; or- 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.
回答2:
'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.
回答3:
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
};
}]);
来源:https://stackoverflow.com/questions/21616766/angularjs-promise-callback-not-trigged-in-jasminejs-test