Skip to content

Commit

Permalink
implement spyOnProperty method (#5107)
Browse files Browse the repository at this point in the history
* feat: implement spyOnProperty method, fixes #5106

* style: fix indentation of mock, runtime, jasmine2

* test: fix failing tests for spyOnProperty

* test: implement tests for #1214

* style: fix eslint errors

* refactor: proxy spyOnProperty call behind spyOn

* refactor: remove useless console.log

* style: fix eslint errors

* types: remove declaration of spyOnProperty

* docs: add documentation for accessType argument of spyOn

* docs: fix typo in spyOn docs

* test(spyOn): fix typo in should throw on invalid input

* test(spyOn): add tests for setters

* docs(spyOn): add example for spying on setters

* style: fix eslint errors

* refactor: format error messages with getErrorMsg()

* style: fix eslint errors

* revert: restore snapshotState.getUncheckedKeys()
  • Loading branch information
phra authored and cpojer committed Jan 8, 2018
1 parent 6d353cc commit 490f435
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 5 deletions.
57 changes: 57 additions & 0 deletions docs/JestObjectAPI.md
Expand Up @@ -418,3 +418,60 @@ test('plays video', () => {
spy.mockRestore();
});
```

### `jest.spyOn(object, methodName, accessType?)`
##### available in Jest **x.x.x+**

Since Jest x.x.x+, the `jest.spyOn` method takes an optional third argument that can be `'get'` or `'get'` in order to install a spy as a getter or a setter respectively. This is also needed when you need a spy an existing getter/setter method.

Example:

```js
const video = {
get play() { // it's a getter!
return true;
},
};

module.exports = video;

const audio = {
_volume: false,
set volume(value) { // it's a setter!
this._volume = value;
},
get volume() {
return this._volume;
}
};

module.exports = video;
```

Example test:

```js
const video = require('./video');

test('plays video', () => {
const spy = jest.spyOn(video, 'play', 'get'); // we pass 'get'
const isPlaying = video.play;

expect(spy).toHaveBeenCalled();
expect(isPlaying).toBe(true);

spy.mockReset();
spy.mockRestore();
});

test('plays audio', () => {
const spy = jest.spyOn(video, 'play', 'set'); // we pass 'set'
video.volume = 100;

expect(spy).toHaveBeenCalled();
expect(video.volume).toBe(100);

spy.mockReset();
spy.mockRestore();
});
```
4 changes: 2 additions & 2 deletions packages/jest-jasmine2/src/jasmine/jasmine_light.js
Expand Up @@ -116,8 +116,8 @@ exports.interface = function(jasmine: Jasmine, env: any) {
return env.fail.apply(env, arguments);
},

spyOn(obj: Object, methodName: string) {
return env.spyOn(obj, methodName);
spyOn(obj: Object, methodName: string, accessType?: string) {
return env.spyOn(obj, methodName, accessType);
},

jsApiReporter: new jasmine.JsApiReporter({
Expand Down
82 changes: 81 additions & 1 deletion packages/jest-jasmine2/src/jasmine/spy_registry.js
Expand Up @@ -64,7 +64,11 @@ export default function SpyRegistry(options: Object) {
this.respy = allow;
};

this.spyOn = function(obj, methodName) {
this.spyOn = function(obj, methodName, accessType?: string) {
if (accessType) {
return this._spyOnProperty(obj, methodName, accessType);
}

if (obj === void 0) {
throw new Error(
getErrorMsg(
Expand Down Expand Up @@ -129,6 +133,82 @@ export default function SpyRegistry(options: Object) {
return spiedMethod;
};

this._spyOnProperty = function(obj, propertyName, accessType = 'get') {
if (!obj) {
throw new Error(
getErrorMsg('could not find an object to spy upon for ' + propertyName),
);
}

if (!propertyName) {
throw new Error(getErrorMsg('No property name supplied'));
}

let descriptor;
try {
descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);
} catch (e) {
// IE 8 doesn't support `definePropery` on non-DOM nodes
}

if (!descriptor) {
throw new Error(getErrorMsg(propertyName + ' property does not exist'));
}

if (!descriptor.configurable) {
throw new Error(
getErrorMsg(propertyName + ' is not declared configurable'),
);
}

if (!descriptor[accessType]) {
throw new Error(
getErrorMsg(
'Property ' +
propertyName +
' does not have access type ' +
accessType,
),
);
}

if (obj[propertyName] && isSpy(obj[propertyName])) {
if (this.respy) {
return obj[propertyName];
} else {
throw new Error(
getErrorMsg(propertyName + ' has already been spied upon'),
);
}
}

const originalDescriptor = descriptor;
const spiedProperty = createSpy(propertyName, descriptor[accessType]);
let restoreStrategy;

if (Object.prototype.hasOwnProperty.call(obj, propertyName)) {
restoreStrategy = function() {
Object.defineProperty(obj, propertyName, originalDescriptor);
};
} else {
restoreStrategy = function() {
delete obj[propertyName];
};
}

currentSpies().push({
restoreObjectToOriginalState: restoreStrategy,
});

const spiedDescriptor = Object.assign({}, descriptor, {
[accessType]: spiedProperty,
});

Object.defineProperty(obj, propertyName, spiedDescriptor);

return spiedProperty;
};

this.clearSpies = function() {
const spies = currentSpies();
for (let i = spies.length - 1; i >= 0; i--) {
Expand Down
118 changes: 118 additions & 0 deletions packages/jest-mock/src/__tests__/jest_mock.test.js
Expand Up @@ -666,4 +666,122 @@ describe('moduleMocker', () => {
expect(spy2.mock.calls.length).toBe(1);
});
});

describe('spyOnProperty', () => {
it('should work - getter', () => {
let isOriginalCalled = false;
let originalCallThis;
let originalCallArguments;
const obj = {
get method() {
return function() {
isOriginalCalled = true;
originalCallThis = this;
originalCallArguments = arguments;
};
},
};

const spy = moduleMocker.spyOn(obj, 'method', 'get');

const thisArg = {this: true};
const firstArg = {first: true};
const secondArg = {second: true};
obj.method.call(thisArg, firstArg, secondArg);
expect(isOriginalCalled).toBe(true);
expect(originalCallThis).toBe(thisArg);
expect(originalCallArguments.length).toBe(2);
expect(originalCallArguments[0]).toBe(firstArg);
expect(originalCallArguments[1]).toBe(secondArg);
expect(spy).toHaveBeenCalled();

isOriginalCalled = false;
originalCallThis = null;
originalCallArguments = null;
spy.mockReset();
spy.mockRestore();
obj.method.call(thisArg, firstArg, secondArg);
expect(isOriginalCalled).toBe(true);
expect(originalCallThis).toBe(thisArg);
expect(originalCallArguments.length).toBe(2);
expect(originalCallArguments[0]).toBe(firstArg);
expect(originalCallArguments[1]).toBe(secondArg);
expect(spy).not.toHaveBeenCalled();
});

it('should work - setter', () => {
const obj = {
_property: false,
set property(value) {
this._property = value;
},
get property() {
return this._property;
},
};

const spy = moduleMocker.spyOn(obj, 'property', 'set');
obj.property = true;
expect(spy).toHaveBeenCalled();
expect(obj.property).toBe(true);
obj.property = false;
spy.mockReset();
spy.mockRestore();
obj.property = true;
expect(spy).not.toHaveBeenCalled();
expect(obj.property).toBe(true);
});

it('should throw on invalid input', () => {
expect(() => {
moduleMocker.spyOn(null, 'method');
}).toThrow();
expect(() => {
moduleMocker.spyOn({}, 'method');
}).toThrow();
expect(() => {
moduleMocker.spyOn({method: 10}, 'method');
}).toThrow();
});

it('supports restoring all spies', () => {
let methodOneCalls = 0;
let methodTwoCalls = 0;
const obj = {
get methodOne() {
return function() {
methodOneCalls++;
};
},
get methodTwo() {
return function() {
methodTwoCalls++;
};
},
};

const spy1 = moduleMocker.spyOn(obj, 'methodOne', 'get');
const spy2 = moduleMocker.spyOn(obj, 'methodTwo', 'get');

// First, we call with the spies: both spies and both original functions
// should be called.
obj.methodOne();
obj.methodTwo();
expect(methodOneCalls).toBe(1);
expect(methodTwoCalls).toBe(1);
expect(spy1.mock.calls.length).toBe(1);
expect(spy2.mock.calls.length).toBe(1);

moduleMocker.restoreAllMocks();

// Then, after resetting all mocks, we call methods again. Only the real
// methods should bump their count, not the spies.
obj.methodOne();
obj.methodTwo();
expect(methodOneCalls).toBe(2);
expect(methodTwoCalls).toBe(2);
expect(spy1.mock.calls.length).toBe(1);
expect(spy2.mock.calls.length).toBe(1);
});
});
});
66 changes: 65 additions & 1 deletion packages/jest-mock/src/index.js
Expand Up @@ -659,7 +659,11 @@ class ModuleMockerClass {
return fn;
}

spyOn(object: any, methodName: any): any {
spyOn(object: any, methodName: any, accessType?: string): any {
if (accessType) {
return this._spyOnProperty(object, methodName, accessType);
}

if (typeof object !== 'object' && typeof object !== 'function') {
throw new Error(
'Cannot spyOn on a primitive value; ' + this._typeOf(object) + ' given',
Expand Down Expand Up @@ -691,6 +695,66 @@ class ModuleMockerClass {
return object[methodName];
}

_spyOnProperty(obj: any, propertyName: any, accessType: string = 'get'): any {
if (typeof obj !== 'object' && typeof obj !== 'function') {
throw new Error(
'Cannot spyOn on a primitive value; ' + this._typeOf(obj) + ' given',
);
}

if (!obj) {
throw new Error(
'spyOn could not find an object to spy upon for ' + propertyName + '',
);
}

if (!propertyName) {
throw new Error('No property name supplied');
}

const descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);

if (!descriptor) {
throw new Error(propertyName + ' property does not exist');
}

if (!descriptor.configurable) {
throw new Error(propertyName + ' is not declared configurable');
}

if (!descriptor[accessType]) {
throw new Error(
'Property ' + propertyName + ' does not have access type ' + accessType,
);
}

const original = descriptor[accessType];

if (!this.isMockFunction(original)) {
if (typeof original !== 'function') {
throw new Error(
'Cannot spy the ' +
propertyName +
' property because it is not a function; ' +
this._typeOf(original) +
' given instead',
);
}

descriptor[accessType] = this._makeComponent({type: 'function'}, () => {
descriptor[accessType] = original;
Object.defineProperty(obj, propertyName, descriptor);
});

descriptor[accessType].mockImplementation(function() {
return original.apply(this, arguments);
});
}

Object.defineProperty(obj, propertyName, descriptor);
return descriptor[accessType];
}

clearAllMocks() {
this._mockState = new WeakMap();
}
Expand Down
21 changes: 21 additions & 0 deletions packages/jest-runtime/src/__tests__/runtime_jest_spy_on.test.js
Expand Up @@ -35,4 +35,25 @@ describe('Runtime', () => {
expect(spy).toHaveBeenCalled();
}));
});

describe('jest.spyOnProperty', () => {
it('calls the original function', () =>
createRuntime(__filename).then(runtime => {
const root = runtime.requireModule(runtime.__mockRootPath);

let isOriginalCalled = false;
const obj = {
get method() {
return () => (isOriginalCalled = true);
},
};

const spy = root.jest.spyOn(obj, 'method', 'get');

obj.method();

expect(isOriginalCalled).toBe(true);
expect(spy).toHaveBeenCalled();
}));
});
});

0 comments on commit 490f435

Please sign in to comment.