Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement spyOnProperty method #5107

Merged
merged 18 commits into from Jan 8, 2018
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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();
});
```
3 changes: 2 additions & 1 deletion packages/jest-jasmine2/src/index.js
Expand Up @@ -161,9 +161,10 @@ const addSnapshotData = (results, snapshotState) => {
});

const uncheckedCount = snapshotState.getUncheckedCount();
const uncheckedKeys = snapshotState.getUncheckedKeys();
let uncheckedKeys;

if (uncheckedCount) {
uncheckedKeys = snapshotState.getUncheckedKeys();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this changed? This seems unrelated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i got runtime errors when developing this feature when uncheckedCount is falsy. do you think that this change is not required?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be changed in a separate commit, with a test that is fixed by your change. Please revert it from this PR and send one for this issues specifically. Thank you.

snapshotState.removeUncheckedKeys();
}

Expand Down
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
75 changes: 74 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,75 @@ export default function SpyRegistry(options: Object) {
return spiedMethod;
};

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an empty string at the end. Can we end the sentence with a .? Also, can we prefix the error with "Jest: "?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i will do it.

Copy link
Contributor Author

@phra phra Jan 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cpojer in the end i've used getErrorMsg() function as in the spyOn function. is that better than manual format the string, isn'it?
see bd44ca5

);
}

if (!propertyName) {
throw new Error('No property name supplied');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i will fix it.

}

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(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,
);
}

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