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.