0

My question: In Karma, I am mocking an injected service while testing the actual service. The mocked service gets some data, and sends back a promise. I can't figure out what I am doing wrong.

I know there are a number of issues with the actual "Login" mechanism, but I am using it to illustrate this Karma question. I do not plan to use it as production code. (However, any suggestions for a better illustration of the problem are welcome!)

First, I wrote this in a generic .js file, and said "node testcode.js"

testcode.js:

function Login(name,password,callback){
    setTimeout(function(){
        var response;
        var promise = getByUserName();
        promise.then(successCb);

        function successCb(userObj){
            if ( userObj != null && userObj.password === password ) {
                response = { success : true };
            } else {
                response = { success: false, message: 'Username or password is incorrect' };
            }
            callback(response);
        };
    },200);
}

function getByUserName(){
    return Promise.resolve(user);
}

var user = {
    username : 'test',
    id : 'testId',
    password : 'test'
};

var test = undefined;
Login(user.username,user.password,testCb);

function testCb(response){
    test = response;
    console.log("Final: " + JSON.stringify(test));
}

This gives me my expected result:

Final: {"success":true}

Now, I try to repeat this in Karma...

TestService:

(function(){
    "use strict";

    angular.module('TestModule').service('TestService',TestService);
    TestService.$inject = ['$http','$cookieStore','$rootScope','$timeout','RealService'];
})();

    function TestService($http,$cookieStore,$rootScope,$timeout,RealService){
        var service = {};
        service.Login = Login;
        /* more stuff */
        return service;

        function Login(username,password,callback){
            $timeout(function () {
                var response;
                var promise = UserService.GetByUsername(username);
                promise.then(successCb);

                function successCb(user){
                    if (user !== null && user.password === password) {
                        response = { success: true };
                    } else {
                        response = { success: false, message: 'Username or password is incorrect' };
                    }
                    callback(response);
                };
            }, 1000);
        }
    }

My Karma-Jasmine Test:

describe('TestModule',function(){
    beforeEach(module('TestModule'));

    describe('TestService',function(){

        var service,$rootScope,
        user = {
            username : 'test',
            id : 'testId',
            password : 'test'
        };

        function MockService() {
            return {
                GetByUsername : function() {
                    return Promise.resolve(user);
                }
            };
        };

        beforeEach(function(){
            module(function($provide){
                $provide.service('RealService',MockService);
            });

            inject(['$rootScope','TestService',
                function($rs,ts){
                    $rootScope = $rs;
                    service = ts;
                }]);
        });

        /**
         * ERROR: To the best of my knowledge, this should not pass
         */
        it('should Login',function(){
            expect(service).toBeDefined();

            var answer = {"success":true};
            service.Login(user.username,user.password,testCb);

            //$rootScope.$apply(); <-- DID NOTHING -->
            //$rootScope.$digest(); <-- DID NOTHING -->

            function testCb(response){
                console.log("I'm never called");
                expect(response.success).toBe(false);
                expect(true).toBe(false);
            };
        });
    });

});

Why is the promise not being resolved? I have tried messing with $rootScope.$digest() based on similar questions I have read on SO, but nothing seems to get testCb to be called.

westandy
  • 1,360
  • 2
  • 16
  • 41
  • Where have you called `$rootScope.$digest()`? It is not in the code. Is there a real reason why promise chain is polluted with callback? Does `getByUserName` return native promise and not `$q` by intention or by mistake? – Estus Flask Jun 06 '16 at 18:38
  • $rootScope.$digest() - I put it in the code in various places, but it never had any effect in the outcome. So, I currently do not have it in there. Frankly, I do not know exactly how to use it. getByUsername returns native promise by intention. Frankly, I don't really understand how to use $q. After reading this article, http://www.codelord.net/2015/09/24/$q-dot-defer-youre-doing-it-wrong/, I just threw my hands up and thought save $q for another day. – westandy Jun 06 '16 at 19:02
  • I've been working JS for a couple of years now, Angular for 6 months. I felt like I had the basics down, but things like $digest and $q have taught me otherwise. Karma and unit testing directives have pretty much crushed my soul, as well (see my other questions on directives in karma). – westandy Jun 06 '16 at 19:04
  • 1
    $q promises are synchronous. Native promise are asynchronous. $timeout is synchronous (it should do `$timeout.flush()`, which wasn't done in this case). setTimeout is asynchronous (can be treated with `jasmine.clock`, it is not for now). Async specs should be treated in Jasmine accordingly (they are not for now). If it is possible to use $q and $timeout exclusively in Angular app, this should be done. I guess you've got too many unknown concepts here that should be sorted out one by one. – Estus Flask Jun 06 '16 at 19:26
  • @estus - thanks. It sounds like I need to break down this mess. The module I'm testing does what I want (it's a mock Login for my app, until my server-side gets authorization implemented). That's why I haven't flushed out the $timeout and $q issues you are illustrating. – westandy Jun 06 '16 at 22:32
  • @estus - you are fantastic. $timeout.flush()... who knew? Apparently not I! I'll make some changes, and post an answer. – westandy Jun 06 '16 at 22:35
  • If auth is going to be implemented with Angular ($http), it would be more painless to stick to Angular stuff ($q and $timeout) for now. Angular's ngMock strongly proposes sync specs, while async specs may be a bit cumbersome in Jasmine. – Estus Flask Jun 06 '16 at 22:42
  • That would explain a lot of the trouble I have had. I kept re-studying promises, but it turns out, I'm in a synchronous test environment. I knew I was slow, but wow... anyway, take a look at my answer, and please feel free to critique, or offer any suggestions on how I can credit you for it. – westandy Jun 06 '16 at 22:53
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/114057/discussion-between-westandy-and-estus). – westandy Jun 07 '16 at 17:59

1 Answers1

0

Somebody let me know if I can give 'estus' the credit for this answer.

The magic is in the $timeout angular mock service: https://docs.angularjs.org/api/ngMock/service/$timeout

and the $q service: https://docs.angularjs.org/api/ng/service/$q

I updated two things. First, my MockUserService is using $q.defer instead of native promise. As 'estus' stated, $q promises are synchronous, which matters in a karma-jasmine test.

    function MockUserService(){
        return {
            GetByUsername : function(unusedVariable){
                //The infamous Deferred antipattern:
                //var defer = $q.defer();
                //defer.resolve(user);
                //return defer.promise;
                return $q.resolve(user);
            }
        };
    };

The next update I made is with the $timeout service:

    it('should Login',function(){
        expect(service).toBeDefined();

        service.Login(user.username,user.password,testCb);
        $timeout.flush(); <-- NEW, forces the $timeout in TestService to execute -->
        $timeout.verifyNoPendingTasks(); <-- NEW -->

        function testCb(response) {
            expect(response.success).toBe(true);
        };
    });

Finally, because I'm using $q and $timeout in my test, I had to update my inject method in my beforeEach:

    inject([
        '$q','$rootScope','$timeout','TestService',
        function(_$q_,$rs,$to,ts) {
            $q = _$q_;
            $rootScope = $rs;
            $timeout = $to;
            service = ts;
        }
    ]);
westandy
  • 1,360
  • 2
  • 16
  • 41
  • 1
    [`$q.defer`](https://github.com/petkaantonov/bluebird/wiki/Promise-anti-patterns#the-deferred-anti-pattern) is known as Deferred antipattern. It is there for jQuery compatibility, `$q.resolve(...)` (Angular 1.4+) or `$q.when(...)` may be used instead. `$timeout.verifyNoPendingTasks()` is necessary only when `$timeout.flush` is used with 'max delay' argument, to make sure that there were no larger timeouts in the queue. – Estus Flask Jun 07 '16 at 08:04