Skip to content

Commit 490f435

Browse files
phracpojer
authored andcommitted
implement spyOnProperty method (#5107)
* 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()
1 parent 6d353cc commit 490f435

File tree

7 files changed

+345
-5
lines changed

7 files changed

+345
-5
lines changed

docs/JestObjectAPI.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,60 @@ test('plays video', () => {
418418
spy.mockRestore();
419419
});
420420
```
421+
422+
### `jest.spyOn(object, methodName, accessType?)`
423+
##### available in Jest **x.x.x+**
424+
425+
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.
426+
427+
Example:
428+
429+
```js
430+
const video = {
431+
get play() { // it's a getter!
432+
return true;
433+
},
434+
};
435+
436+
module.exports = video;
437+
438+
const audio = {
439+
_volume: false,
440+
set volume(value) { // it's a setter!
441+
this._volume = value;
442+
},
443+
get volume() {
444+
return this._volume;
445+
}
446+
};
447+
448+
module.exports = video;
449+
```
450+
451+
Example test:
452+
453+
```js
454+
const video = require('./video');
455+
456+
test('plays video', () => {
457+
const spy = jest.spyOn(video, 'play', 'get'); // we pass 'get'
458+
const isPlaying = video.play;
459+
460+
expect(spy).toHaveBeenCalled();
461+
expect(isPlaying).toBe(true);
462+
463+
spy.mockReset();
464+
spy.mockRestore();
465+
});
466+
467+
test('plays audio', () => {
468+
const spy = jest.spyOn(video, 'play', 'set'); // we pass 'set'
469+
video.volume = 100;
470+
471+
expect(spy).toHaveBeenCalled();
472+
expect(video.volume).toBe(100);
473+
474+
spy.mockReset();
475+
spy.mockRestore();
476+
});
477+
```

packages/jest-jasmine2/src/jasmine/jasmine_light.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ exports.interface = function(jasmine: Jasmine, env: any) {
116116
return env.fail.apply(env, arguments);
117117
},
118118

119-
spyOn(obj: Object, methodName: string) {
120-
return env.spyOn(obj, methodName);
119+
spyOn(obj: Object, methodName: string, accessType?: string) {
120+
return env.spyOn(obj, methodName, accessType);
121121
},
122122

123123
jsApiReporter: new jasmine.JsApiReporter({

packages/jest-jasmine2/src/jasmine/spy_registry.js

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ export default function SpyRegistry(options: Object) {
6464
this.respy = allow;
6565
};
6666

67-
this.spyOn = function(obj, methodName) {
67+
this.spyOn = function(obj, methodName, accessType?: string) {
68+
if (accessType) {
69+
return this._spyOnProperty(obj, methodName, accessType);
70+
}
71+
6872
if (obj === void 0) {
6973
throw new Error(
7074
getErrorMsg(
@@ -129,6 +133,82 @@ export default function SpyRegistry(options: Object) {
129133
return spiedMethod;
130134
};
131135

136+
this._spyOnProperty = function(obj, propertyName, accessType = 'get') {
137+
if (!obj) {
138+
throw new Error(
139+
getErrorMsg('could not find an object to spy upon for ' + propertyName),
140+
);
141+
}
142+
143+
if (!propertyName) {
144+
throw new Error(getErrorMsg('No property name supplied'));
145+
}
146+
147+
let descriptor;
148+
try {
149+
descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);
150+
} catch (e) {
151+
// IE 8 doesn't support `definePropery` on non-DOM nodes
152+
}
153+
154+
if (!descriptor) {
155+
throw new Error(getErrorMsg(propertyName + ' property does not exist'));
156+
}
157+
158+
if (!descriptor.configurable) {
159+
throw new Error(
160+
getErrorMsg(propertyName + ' is not declared configurable'),
161+
);
162+
}
163+
164+
if (!descriptor[accessType]) {
165+
throw new Error(
166+
getErrorMsg(
167+
'Property ' +
168+
propertyName +
169+
' does not have access type ' +
170+
accessType,
171+
),
172+
);
173+
}
174+
175+
if (obj[propertyName] && isSpy(obj[propertyName])) {
176+
if (this.respy) {
177+
return obj[propertyName];
178+
} else {
179+
throw new Error(
180+
getErrorMsg(propertyName + ' has already been spied upon'),
181+
);
182+
}
183+
}
184+
185+
const originalDescriptor = descriptor;
186+
const spiedProperty = createSpy(propertyName, descriptor[accessType]);
187+
let restoreStrategy;
188+
189+
if (Object.prototype.hasOwnProperty.call(obj, propertyName)) {
190+
restoreStrategy = function() {
191+
Object.defineProperty(obj, propertyName, originalDescriptor);
192+
};
193+
} else {
194+
restoreStrategy = function() {
195+
delete obj[propertyName];
196+
};
197+
}
198+
199+
currentSpies().push({
200+
restoreObjectToOriginalState: restoreStrategy,
201+
});
202+
203+
const spiedDescriptor = Object.assign({}, descriptor, {
204+
[accessType]: spiedProperty,
205+
});
206+
207+
Object.defineProperty(obj, propertyName, spiedDescriptor);
208+
209+
return spiedProperty;
210+
};
211+
132212
this.clearSpies = function() {
133213
const spies = currentSpies();
134214
for (let i = spies.length - 1; i >= 0; i--) {

packages/jest-mock/src/__tests__/jest_mock.test.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,4 +666,122 @@ describe('moduleMocker', () => {
666666
expect(spy2.mock.calls.length).toBe(1);
667667
});
668668
});
669+
670+
describe('spyOnProperty', () => {
671+
it('should work - getter', () => {
672+
let isOriginalCalled = false;
673+
let originalCallThis;
674+
let originalCallArguments;
675+
const obj = {
676+
get method() {
677+
return function() {
678+
isOriginalCalled = true;
679+
originalCallThis = this;
680+
originalCallArguments = arguments;
681+
};
682+
},
683+
};
684+
685+
const spy = moduleMocker.spyOn(obj, 'method', 'get');
686+
687+
const thisArg = {this: true};
688+
const firstArg = {first: true};
689+
const secondArg = {second: true};
690+
obj.method.call(thisArg, firstArg, secondArg);
691+
expect(isOriginalCalled).toBe(true);
692+
expect(originalCallThis).toBe(thisArg);
693+
expect(originalCallArguments.length).toBe(2);
694+
expect(originalCallArguments[0]).toBe(firstArg);
695+
expect(originalCallArguments[1]).toBe(secondArg);
696+
expect(spy).toHaveBeenCalled();
697+
698+
isOriginalCalled = false;
699+
originalCallThis = null;
700+
originalCallArguments = null;
701+
spy.mockReset();
702+
spy.mockRestore();
703+
obj.method.call(thisArg, firstArg, secondArg);
704+
expect(isOriginalCalled).toBe(true);
705+
expect(originalCallThis).toBe(thisArg);
706+
expect(originalCallArguments.length).toBe(2);
707+
expect(originalCallArguments[0]).toBe(firstArg);
708+
expect(originalCallArguments[1]).toBe(secondArg);
709+
expect(spy).not.toHaveBeenCalled();
710+
});
711+
712+
it('should work - setter', () => {
713+
const obj = {
714+
_property: false,
715+
set property(value) {
716+
this._property = value;
717+
},
718+
get property() {
719+
return this._property;
720+
},
721+
};
722+
723+
const spy = moduleMocker.spyOn(obj, 'property', 'set');
724+
obj.property = true;
725+
expect(spy).toHaveBeenCalled();
726+
expect(obj.property).toBe(true);
727+
obj.property = false;
728+
spy.mockReset();
729+
spy.mockRestore();
730+
obj.property = true;
731+
expect(spy).not.toHaveBeenCalled();
732+
expect(obj.property).toBe(true);
733+
});
734+
735+
it('should throw on invalid input', () => {
736+
expect(() => {
737+
moduleMocker.spyOn(null, 'method');
738+
}).toThrow();
739+
expect(() => {
740+
moduleMocker.spyOn({}, 'method');
741+
}).toThrow();
742+
expect(() => {
743+
moduleMocker.spyOn({method: 10}, 'method');
744+
}).toThrow();
745+
});
746+
747+
it('supports restoring all spies', () => {
748+
let methodOneCalls = 0;
749+
let methodTwoCalls = 0;
750+
const obj = {
751+
get methodOne() {
752+
return function() {
753+
methodOneCalls++;
754+
};
755+
},
756+
get methodTwo() {
757+
return function() {
758+
methodTwoCalls++;
759+
};
760+
},
761+
};
762+
763+
const spy1 = moduleMocker.spyOn(obj, 'methodOne', 'get');
764+
const spy2 = moduleMocker.spyOn(obj, 'methodTwo', 'get');
765+
766+
// First, we call with the spies: both spies and both original functions
767+
// should be called.
768+
obj.methodOne();
769+
obj.methodTwo();
770+
expect(methodOneCalls).toBe(1);
771+
expect(methodTwoCalls).toBe(1);
772+
expect(spy1.mock.calls.length).toBe(1);
773+
expect(spy2.mock.calls.length).toBe(1);
774+
775+
moduleMocker.restoreAllMocks();
776+
777+
// Then, after resetting all mocks, we call methods again. Only the real
778+
// methods should bump their count, not the spies.
779+
obj.methodOne();
780+
obj.methodTwo();
781+
expect(methodOneCalls).toBe(2);
782+
expect(methodTwoCalls).toBe(2);
783+
expect(spy1.mock.calls.length).toBe(1);
784+
expect(spy2.mock.calls.length).toBe(1);
785+
});
786+
});
669787
});

packages/jest-mock/src/index.js

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,11 @@ class ModuleMockerClass {
659659
return fn;
660660
}
661661

662-
spyOn(object: any, methodName: any): any {
662+
spyOn(object: any, methodName: any, accessType?: string): any {
663+
if (accessType) {
664+
return this._spyOnProperty(object, methodName, accessType);
665+
}
666+
663667
if (typeof object !== 'object' && typeof object !== 'function') {
664668
throw new Error(
665669
'Cannot spyOn on a primitive value; ' + this._typeOf(object) + ' given',
@@ -691,6 +695,66 @@ class ModuleMockerClass {
691695
return object[methodName];
692696
}
693697

698+
_spyOnProperty(obj: any, propertyName: any, accessType: string = 'get'): any {
699+
if (typeof obj !== 'object' && typeof obj !== 'function') {
700+
throw new Error(
701+
'Cannot spyOn on a primitive value; ' + this._typeOf(obj) + ' given',
702+
);
703+
}
704+
705+
if (!obj) {
706+
throw new Error(
707+
'spyOn could not find an object to spy upon for ' + propertyName + '',
708+
);
709+
}
710+
711+
if (!propertyName) {
712+
throw new Error('No property name supplied');
713+
}
714+
715+
const descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);
716+
717+
if (!descriptor) {
718+
throw new Error(propertyName + ' property does not exist');
719+
}
720+
721+
if (!descriptor.configurable) {
722+
throw new Error(propertyName + ' is not declared configurable');
723+
}
724+
725+
if (!descriptor[accessType]) {
726+
throw new Error(
727+
'Property ' + propertyName + ' does not have access type ' + accessType,
728+
);
729+
}
730+
731+
const original = descriptor[accessType];
732+
733+
if (!this.isMockFunction(original)) {
734+
if (typeof original !== 'function') {
735+
throw new Error(
736+
'Cannot spy the ' +
737+
propertyName +
738+
' property because it is not a function; ' +
739+
this._typeOf(original) +
740+
' given instead',
741+
);
742+
}
743+
744+
descriptor[accessType] = this._makeComponent({type: 'function'}, () => {
745+
descriptor[accessType] = original;
746+
Object.defineProperty(obj, propertyName, descriptor);
747+
});
748+
749+
descriptor[accessType].mockImplementation(function() {
750+
return original.apply(this, arguments);
751+
});
752+
}
753+
754+
Object.defineProperty(obj, propertyName, descriptor);
755+
return descriptor[accessType];
756+
}
757+
694758
clearAllMocks() {
695759
this._mockState = new WeakMap();
696760
}

packages/jest-runtime/src/__tests__/runtime_jest_spy_on.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,25 @@ describe('Runtime', () => {
3535
expect(spy).toHaveBeenCalled();
3636
}));
3737
});
38+
39+
describe('jest.spyOnProperty', () => {
40+
it('calls the original function', () =>
41+
createRuntime(__filename).then(runtime => {
42+
const root = runtime.requireModule(runtime.__mockRootPath);
43+
44+
let isOriginalCalled = false;
45+
const obj = {
46+
get method() {
47+
return () => (isOriginalCalled = true);
48+
},
49+
};
50+
51+
const spy = root.jest.spyOn(obj, 'method', 'get');
52+
53+
obj.method();
54+
55+
expect(isOriginalCalled).toBe(true);
56+
expect(spy).toHaveBeenCalled();
57+
}));
58+
});
3859
});

0 commit comments

Comments
 (0)