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