DEV Community

loading...

Jasmine Gotcha: spyOn(…).and.callThrough() makes only a shallow copy of arguments

Alexey Feigin
・2 min read

I was recently writing some frontend JavaScript tests using the Jasmine framework, and came across this little issue I’ll describe here.

Suppose we want to test if a method is called, but also want it to execute it.

// Base code
Obj.prototype.outerMethod = function (config = {}) {
  if (!config.subConfig) {
    config.subConfig = {};
  }
  config.subConfig.option = true;
  return this.innerMethodReturning0(config);
};
// (Excuse the ES5-style method definition…)

We would like to test that innerMethodReturning0 is called with the correct argument, but also for some reason want it to execute. In this case, test that innerMethodReturning0 is being called with the correct config.

(In reality we should test innerMethodReturning0 separately instead of calling through… This is contrived in the interests of keeping it simple.)

// Test code
const obj = new Obj();
spyOn(obj, 'innerMethodReturning0').and.callThrough();
const result = obj.innerMethodReturning0();
expect(obj.innerMethodReturning0).toHaveBeenCalledWith({ subConfig: { option: true } });
expect(result).toEqual(0);

This may be fine, but let’s consider what happens if innerMethodReturning0 mutates its argument.

// innerMethodReturning0 shallow mutation implementation
Obj.prototype.innerMethodReturning0 = function (config) {
  config.shallowProperty = true;
  return 0;
}

This works.

Now let’s consider the case where innerMethodReturning0 mutates a deep property of the argument. For example, it could set its own default setting of config.subConfig.option2: true on the config object.

// innerMethodReturning0 deep mutation implementation
Obj.prototype.innerMethodReturning0 = function (config) {
  config.subConfig.option2 = true;
  return 0;
}

In this case the test will fail with:

Expected obj.innerMethodReturning0 to have been called with
{ subConfig: { option: true } }
but was called with
{ subConfig: { option: true, option2: true } }.

This is because Jasmine only makes a shallow copy of the actual arguments at the entry to the spy, to use for comparison later. This means that if innerMethodReturning0 mutates a deep property on the argument, the actual argument object tree will also be mutated.

The following is one partial workaround, in which we maintain our own deep clone of the argument.

// Test code
const obj = new Obj();
const callArgs = [];
const innerMethodReturning0 = obj.innerMethodReturning0.bind(obj);
spyOn(obj, 'innerMethodReturning0').and.callFake((config) => {
  callArgs.push(JSON.parse(JSON.stringify(config)));
  return innerMethodReturning0(config);
});
const result = obj.innerMethodReturning0();
expect(callArgs.length).toEqual(1);
expect(callArgs[0]).toEqual({ subConfig: { option: true } });
expect(result).toEqual(0);

In general, deep cloning in JavaScript is suspect because error objects, functions, DOM nodes, and WeakMaps cannot be cloned (not to mention circular references in objects).

I have not tested this in Mocha or other testing frameworks, but I suspect that due to the CPU cost and limitations of deep cloning they would suffer from similar problems with a setup like this. (Please write in the comments if you know.)

It is probably best to avoid the spyOn(…).and.callThrough() pattern when possible. Definitely avoid when the arguments may be mutated.

(Thanks to Ben Woodcock and Yaakov Smith for their feedback on this piece.)

Discussion (0)