Skip to content

Commit ae7aee9

Browse files
hanneslundSimenB
authored andcommitted
feat: add prefer-spy-on rule (#191)
Fixes #185
1 parent 1f658dd commit ae7aee9

File tree

6 files changed

+223
-0
lines changed

6 files changed

+223
-0
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ for more information about extending configuration files.
9595
| [no-test-prefixes][] | Disallow using `f` & `x` prefixes to define focused/skipped tests | | ![fixable-green][] |
9696
| [no-test-return-statement][] | Disallow explicitly returning from tests | | |
9797
| [prefer-expect-assertions][] | Suggest using `expect.assertions()` OR `expect.hasAssertions()` | | |
98+
| [prefer-spy-on][] | Suggest using `jest.spyOn()` | | ![fixable-green][] |
9899
| [prefer-strict-equal][] | Suggest using `toStrictEqual()` | | ![fixable-green][] |
99100
| [prefer-to-be-null][] | Suggest using `toBeNull()` | | ![fixable-green][] |
100101
| [prefer-to-be-undefined][] | Suggest using `toBeUndefined()` | | ![fixable-green][] |
@@ -126,6 +127,7 @@ for more information about extending configuration files.
126127
[no-test-prefixes]: docs/rules/no-test-prefixes.md
127128
[no-test-return-statement]: docs/rules/no-test-return-statement.md
128129
[prefer-expect-assertions]: docs/rules/prefer-expect-assertions.md
130+
[prefer-spy-on]: docs/rules/prefer-spy-on.md
129131
[prefer-strict-equal]: docs/rules/prefer-strict-equal.md
130132
[prefer-to-be-null]: docs/rules/prefer-to-be-null.md
131133
[prefer-to-be-undefined]: docs/rules/prefer-to-be-undefined.md

docs/rules/prefer-spy-on.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Suggest using `jest.spyOn()` (prefer-spy-on)
2+
3+
When mocking a function by overwriting a property you have to manually restore
4+
the original implementation when cleaning up. When using `jest.spyOn()` Jest
5+
keeps track of changes, and they can be restored with `jest.restoreAllMocks()`,
6+
`mockFn.mockRestore()` or by setting `restoreMocks` to `true` in the Jest
7+
config.
8+
9+
Note: The mock created by `jest.spyOn()` still behaves the same as the original
10+
function. The original function can be overwritten with
11+
`mockFn.mockImplementation()` or by some of the
12+
[other mock functions](https://jestjs.io/docs/en/mock-function-api).
13+
14+
```js
15+
Date.now = jest.fn(); // Original behaviour lost, returns undefined
16+
17+
jest.spyOn(Date, 'now'); // Turned into a mock function but behaviour hasn't changed
18+
jest.spyOn(Date, 'now').mockImplementation(() => 10); // Will always return 10
19+
jest.spyOn(Date, 'now').mockReturnValue(10); // Will always return 10
20+
```
21+
22+
## Rule details
23+
24+
This rule triggers a warning if an object's property is overwritten with a jest
25+
mock.
26+
27+
### Default configuration
28+
29+
The following patterns are considered warnings:
30+
31+
```js
32+
Date.now = jest.fn();
33+
Date.now = jest.fn(() => 10);
34+
```
35+
36+
These patterns would not be considered warnings:
37+
38+
```js
39+
jest.spyOn(Date, 'now');
40+
jest.spyOn(Date, 'now').mockImplementation(() => 10);
41+
```

index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const noJestImport = require('./rules/no-jest-import');
1212
const noLargeSnapshots = require('./rules/no-large-snapshots');
1313
const noTestPrefixes = require('./rules/no-test-prefixes');
1414
const noTestReturnStatement = require('./rules/no-test-return-statement');
15+
const preferSpyOn = require('./rules/prefer-spy-on');
1516
const preferToBeNull = require('./rules/prefer-to-be-null');
1617
const preferToBeUndefined = require('./rules/prefer-to-be-undefined');
1718
const preferToContain = require('./rules/prefer-to-contain');
@@ -84,6 +85,7 @@ module.exports = {
8485
'no-large-snapshots': noLargeSnapshots,
8586
'no-test-prefixes': noTestPrefixes,
8687
'no-test-return-statement': noTestReturnStatement,
88+
'prefer-spy-on': preferSpyOn,
8789
'prefer-to-be-null': preferToBeNull,
8890
'prefer-to-be-undefined': preferToBeUndefined,
8991
'prefer-to-contain': preferToContain,

rules/__tests__/prefer-spy-on.test.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
'use strict';
2+
3+
const RuleTester = require('eslint').RuleTester;
4+
const rule = require('../prefer-spy-on');
5+
6+
const ruleTester = new RuleTester({
7+
parserOptions: {
8+
ecmaVersion: 6,
9+
},
10+
});
11+
12+
ruleTester.run('prefer-spy-on', rule, {
13+
valid: [
14+
'Date.now = () => 10',
15+
'window.fetch = jest.fn',
16+
'obj.mock = jest.something()',
17+
'const mock = jest.fn()',
18+
'mock = jest.fn()',
19+
'const mockObj = { mock: jest.fn() }',
20+
'mockObj = { mock: jest.fn() }',
21+
'window[`${name}`] = jest[`fn${expression}`]()',
22+
],
23+
invalid: [
24+
{
25+
code: 'obj.a = jest.fn(); const test = 10;',
26+
errors: [
27+
{
28+
message: 'Use jest.spyOn() instead.',
29+
type: 'AssignmentExpression',
30+
},
31+
],
32+
output: "jest.spyOn(obj, 'a'); const test = 10;",
33+
},
34+
{
35+
code: "Date['now'] = jest['fn']()",
36+
errors: [
37+
{
38+
message: 'Use jest.spyOn() instead.',
39+
type: 'AssignmentExpression',
40+
},
41+
],
42+
output: "jest.spyOn(Date, 'now')",
43+
},
44+
{
45+
code: 'window[`${name}`] = jest[`fn`]()',
46+
errors: [
47+
{
48+
message: 'Use jest.spyOn() instead.',
49+
type: 'AssignmentExpression',
50+
},
51+
],
52+
output: 'jest.spyOn(window, `${name}`)',
53+
},
54+
{
55+
code: "obj['prop' + 1] = jest['fn']()",
56+
errors: [
57+
{
58+
message: 'Use jest.spyOn() instead.',
59+
type: 'AssignmentExpression',
60+
},
61+
],
62+
output: "jest.spyOn(obj, 'prop' + 1)",
63+
},
64+
{
65+
code: 'obj.one.two = jest.fn(); const test = 10;',
66+
errors: [
67+
{
68+
message: 'Use jest.spyOn() instead.',
69+
type: 'AssignmentExpression',
70+
},
71+
],
72+
output: "jest.spyOn(obj.one, 'two'); const test = 10;",
73+
},
74+
{
75+
code: 'obj.a = jest.fn(() => 10)',
76+
errors: [
77+
{
78+
message: 'Use jest.spyOn() instead.',
79+
type: 'AssignmentExpression',
80+
},
81+
],
82+
output: "jest.spyOn(obj, 'a').mockImplementation(() => 10)",
83+
},
84+
{
85+
code:
86+
"obj.a.b = jest.fn(() => ({})).mockReturnValue('default').mockReturnValueOnce('first call'); test();",
87+
errors: [
88+
{
89+
message: 'Use jest.spyOn() instead.',
90+
type: 'AssignmentExpression',
91+
},
92+
],
93+
output:
94+
"jest.spyOn(obj.a, 'b').mockImplementation(() => ({})).mockReturnValue('default').mockReturnValueOnce('first call'); test();",
95+
},
96+
{
97+
code: 'window.fetch = jest.fn(() => ({})).one.two().three().four',
98+
errors: [
99+
{
100+
message: 'Use jest.spyOn() instead.',
101+
type: 'AssignmentExpression',
102+
},
103+
],
104+
output:
105+
"jest.spyOn(window, 'fetch').mockImplementation(() => ({})).one.two().three().four",
106+
},
107+
],
108+
});

rules/prefer-spy-on.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use strict';
2+
3+
const getDocsUrl = require('./util').getDocsUrl;
4+
const getNodeName = require('./util').getNodeName;
5+
6+
const getJestFnCall = node => {
7+
if (node.type !== 'CallExpression' && node.type !== 'MemberExpression') {
8+
return null;
9+
}
10+
11+
const obj = node.callee ? node.callee.object : node.object;
12+
13+
if (obj.type === 'Identifier') {
14+
return node.type === 'CallExpression' &&
15+
getNodeName(node.callee) === 'jest.fn'
16+
? node
17+
: null;
18+
}
19+
20+
return getJestFnCall(obj);
21+
};
22+
23+
module.exports = {
24+
meta: {
25+
docs: {
26+
url: getDocsUrl(__filename),
27+
},
28+
fixable: 'code',
29+
},
30+
create(context) {
31+
return {
32+
AssignmentExpression(node) {
33+
if (node.left.type !== 'MemberExpression') return;
34+
35+
const jestFnCall = getJestFnCall(node.right);
36+
37+
if (!jestFnCall) return;
38+
39+
context.report({
40+
node,
41+
message: 'Use jest.spyOn() instead.',
42+
fix(fixer) {
43+
const leftPropQuote =
44+
node.left.property.type === 'Identifier' ? "'" : '';
45+
const arg = jestFnCall.arguments[0];
46+
const argSource = arg && context.getSourceCode().getText(arg);
47+
const mockImplementation = argSource
48+
? `.mockImplementation(${argSource})`
49+
: '';
50+
51+
return [
52+
fixer.insertTextBefore(node.left, `jest.spyOn(`),
53+
fixer.replaceTextRange(
54+
[node.left.object.end, node.left.property.start],
55+
`, ${leftPropQuote}`
56+
),
57+
fixer.replaceTextRange(
58+
[node.left.property.end, jestFnCall.end],
59+
`${leftPropQuote})${mockImplementation}`
60+
),
61+
];
62+
},
63+
});
64+
},
65+
};
66+
},
67+
};

rules/util.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ const getNodeName = node => {
107107
return node.name;
108108
case 'Literal':
109109
return node.value;
110+
case 'TemplateLiteral':
111+
if (node.expressions.length === 0) return node.quasis[0].value.cooked;
112+
break;
110113
case 'MemberExpression':
111114
return joinNames(getNodeName(node.object), getNodeName(node.property));
112115
}

0 commit comments

Comments
 (0)