diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index daaab8766cd8..64dcfbf982e5 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -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(); +}); +``` diff --git a/packages/jest-jasmine2/src/jasmine/jasmine_light.js b/packages/jest-jasmine2/src/jasmine/jasmine_light.js index 68f4ac1122dd..911377194e92 100644 --- a/packages/jest-jasmine2/src/jasmine/jasmine_light.js +++ b/packages/jest-jasmine2/src/jasmine/jasmine_light.js @@ -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({ diff --git a/packages/jest-jasmine2/src/jasmine/spy_registry.js b/packages/jest-jasmine2/src/jasmine/spy_registry.js index 31d156a7fadb..4a619eaba36f 100644 --- a/packages/jest-jasmine2/src/jasmine/spy_registry.js +++ b/packages/jest-jasmine2/src/jasmine/spy_registry.js @@ -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( @@ -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--) { diff --git a/packages/jest-mock/src/__tests__/jest_mock.test.js b/packages/jest-mock/src/__tests__/jest_mock.test.js index e081c8ecbc7e..d298cba993de 100644 --- a/packages/jest-mock/src/__tests__/jest_mock.test.js +++ b/packages/jest-mock/src/__tests__/jest_mock.test.js @@ -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); + }); + }); }); diff --git a/packages/jest-mock/src/index.js b/packages/jest-mock/src/index.js index 33019e3edb51..7490110d5de1 100644 --- a/packages/jest-mock/src/index.js +++ b/packages/jest-mock/src/index.js @@ -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', @@ -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(); } diff --git a/packages/jest-runtime/src/__tests__/runtime_jest_spy_on.test.js b/packages/jest-runtime/src/__tests__/runtime_jest_spy_on.test.js index 2511e16cf8dd..3c062d1fffcf 100644 --- a/packages/jest-runtime/src/__tests__/runtime_jest_spy_on.test.js +++ b/packages/jest-runtime/src/__tests__/runtime_jest_spy_on.test.js @@ -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(); + })); + }); }); diff --git a/types/Jest.js b/types/Jest.js index 3b04e043e361..45a8f98af0c6 100644 --- a/types/Jest.js +++ b/types/Jest.js @@ -43,7 +43,7 @@ export type Jest = {| runTimersToTime(msToRun: number): void, setMock(moduleName: string, moduleExports: any): Jest, setTimeout(timeout: number): Jest, - spyOn(object: Object, methodName: string): JestMockFn, + spyOn(object: Object, methodName: string, accessType?: string): JestMockFn, unmock(moduleName: string): Jest, useFakeTimers(): Jest, useRealTimers(): Jest,