Question

Code under test:

module lib {
    export class Topic {
        private _callbacks: JQueryCallback;
        public id: string;
        public publish: any;
        public subscribe: any;
        public unsubscribe: any;
        public test: any;

        constructor(id: string) {
            this.id = id;
            this._callbacks = jQuery.Callbacks();
            this.publish = this._callbacks.fire;
            this.subscribe = this._callbacks.add;
            this.unsubscribe = this._callbacks.remove;
        }
    }

    export class Bus {
        private static _topics: Object = {};

        static topic(id: string): Topic {
            var topic = id && this._topics[id];

            if (!topic) {
                topic = new Topic(id);
                if (id) {
                    this._topics[id] = topic;
                }
            }

            return topic;
        }
    }
}

Spec test objects:

module lib {
    class Person {
        private _dfd: JQueryDeferred<Topic>;
        private _topic: Topic;

        constructor(public firstName: string) {
            this._dfd = jQuery.Deferred();
            this._topic = Bus.topic("user:logon");
            this._dfd.done(this._topic.publish);
        }

        logon() {
            this._dfd.resolve(this);
        }
    }

    class ApiService {
        constructor() {
            Bus.topic("user:logon").subscribe(this.callLogonApi);
        }
        callLogonApi(person: Person) {
            console.log("Person.firstname: " + person.firstName);
        }
    }

    describe("Bus", () => {
        var person: Person;
        var apiService: ApiService;

        beforeEach(() => {
            person = new Person("Michael");
            apiService = new ApiService();
            spyOn(apiService, "callLogonApi");

                //or this fails as well
                //spyOn(apiService, "callLogonApi").and.callThrough();
            person.logon();
        });

        it("should create subscription and catch the published event", () => {
            expect(apiService.callLogonApi).toHaveBeenCalled();

                //this fails too 
            //expect(apiService.callLogonApi).toHaveBeenCalledWith(person);
        });
    });
}

The callLogonApi function is called and the console is written to as expected but the output is:

Expected spy callLogonApi to have been called.
Error: Expected spy callLogonApi to have been called.

* This is now working with the constructor of ApiService changed to:

    constructor() {
        Bus.topic("user:logon").subscribe((data)=> { this.callLogonApi(data); });
    }

* And the spyOn requires

        spyOn(apiService, "callLogonApi").and.callThrough();

Thanks to Ryan's great answer!!

Was it helpful?

Solution

Here's a smaller version of what's going on.

First, here's a simpler version of the spyOn method:

function spyOn(obj: any, methodName: string) {
    var prev = obj[methodName];
    obj[methodName] = function() {
        console.log(methodName + ' got called');
        prev();
    }
}

Now let's try this out with a simple class:

/** OK **/
class Thing1 {
    sayHello() {
        console.log('Hello, world');
    }
}

var x = new Thing1();
spyOn(x, 'sayHello');
x.sayHello(); // 'sayHello got called'

This works as expected. On to the deferred version, which is what your code is doing:

/** Not OK **/
class Thing2 {
    private helloMethod;

    constructor() {
        this.helloMethod = this.sayHello;
    }

    deferredHello() {
        window.setTimeout(this.helloMethod, 10);
    }

    sayHello() {
        console.log('Hello, world');
    }
}

var y = new Thing2();
spyOn(y, 'sayHello');
y.deferredHello(); // Why no spy?

Finally, the fixed version. I'll explain why it's fixed shortly:

/** OK now **/
class Thing3 {
    private helloMethod;

    constructor() {
        this.helloMethod = () => { this.sayHello(); }
    }

    deferredHello() {
        window.setTimeout(this.helloMethod, 10);
    }

    sayHello() {
        console.log('Hello, world');
    }   
}

var z = new Thing3();
spyOn(z, 'sayHello');
z.deferredHello(); // Spy works!

What's the deal?

Note that the spyOn function takes an object, wraps the method, and then sets a property on the object itself that replaces the spied function instance. This is very important because it changes where a property lookup of the method name will eventually happen.

In the normal case (Thing1), we overwrite a property (using spyOn) on x and then invoke that same method on x. Everything works because we're calling the exact same function that spyOn wrapped.

In the deferred case (Thing2), y.sayHello changes meaning throughout the code. When we first grab it in the constructor, we're getting the sayHello method from the prototype of the class. When we spyOn y.sayHello, the wrapped function is a new object, but the reference we got earlier in execution is still pointing to the implementation of sayHello in the prototype.

In the fixed case (Thing3), we use a function to more lazily get the value of sayHello, so when z.sayHello changes (because we spied it), the deferredHello invocation "sees" the new method object that's now on the instance object instead of the class prototype.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top