Skip to content

Commit

Permalink
feat: add prefer-spy-on rule (#191)
Browse files Browse the repository at this point in the history
Fixes #185
  • Loading branch information
hanneslund authored and SimenB committed Nov 3, 2018
1 parent 1f658dd commit ae7aee9
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Expand Up @@ -95,6 +95,7 @@ for more information about extending configuration files.
| [no-test-prefixes][] | Disallow using `f` & `x` prefixes to define focused/skipped tests | | ![fixable-green][] |
| [no-test-return-statement][] | Disallow explicitly returning from tests | | |
| [prefer-expect-assertions][] | Suggest using `expect.assertions()` OR `expect.hasAssertions()` | | |
| [prefer-spy-on][] | Suggest using `jest.spyOn()` | | ![fixable-green][] |
| [prefer-strict-equal][] | Suggest using `toStrictEqual()` | | ![fixable-green][] |
| [prefer-to-be-null][] | Suggest using `toBeNull()` | | ![fixable-green][] |
| [prefer-to-be-undefined][] | Suggest using `toBeUndefined()` | | ![fixable-green][] |
Expand Down Expand Up @@ -126,6 +127,7 @@ for more information about extending configuration files.
[no-test-prefixes]: docs/rules/no-test-prefixes.md
[no-test-return-statement]: docs/rules/no-test-return-statement.md
[prefer-expect-assertions]: docs/rules/prefer-expect-assertions.md
[prefer-spy-on]: docs/rules/prefer-spy-on.md
[prefer-strict-equal]: docs/rules/prefer-strict-equal.md
[prefer-to-be-null]: docs/rules/prefer-to-be-null.md
[prefer-to-be-undefined]: docs/rules/prefer-to-be-undefined.md
Expand Down
41 changes: 41 additions & 0 deletions docs/rules/prefer-spy-on.md
@@ -0,0 +1,41 @@
# Suggest using `jest.spyOn()` (prefer-spy-on)

When mocking a function by overwriting a property you have to manually restore
the original implementation when cleaning up. When using `jest.spyOn()` Jest
keeps track of changes, and they can be restored with `jest.restoreAllMocks()`,
`mockFn.mockRestore()` or by setting `restoreMocks` to `true` in the Jest
config.

Note: The mock created by `jest.spyOn()` still behaves the same as the original
function. The original function can be overwritten with
`mockFn.mockImplementation()` or by some of the
[other mock functions](https://jestjs.io/docs/en/mock-function-api).

```js
Date.now = jest.fn(); // Original behaviour lost, returns undefined

jest.spyOn(Date, 'now'); // Turned into a mock function but behaviour hasn't changed
jest.spyOn(Date, 'now').mockImplementation(() => 10); // Will always return 10
jest.spyOn(Date, 'now').mockReturnValue(10); // Will always return 10
```

## Rule details

This rule triggers a warning if an object's property is overwritten with a jest
mock.

### Default configuration

The following patterns are considered warnings:

```js
Date.now = jest.fn();
Date.now = jest.fn(() => 10);
```

These patterns would not be considered warnings:

```js
jest.spyOn(Date, 'now');
jest.spyOn(Date, 'now').mockImplementation(() => 10);
```
2 changes: 2 additions & 0 deletions index.js
Expand Up @@ -12,6 +12,7 @@ const noJestImport = require('./rules/no-jest-import');
const noLargeSnapshots = require('./rules/no-large-snapshots');
const noTestPrefixes = require('./rules/no-test-prefixes');
const noTestReturnStatement = require('./rules/no-test-return-statement');
const preferSpyOn = require('./rules/prefer-spy-on');
const preferToBeNull = require('./rules/prefer-to-be-null');
const preferToBeUndefined = require('./rules/prefer-to-be-undefined');
const preferToContain = require('./rules/prefer-to-contain');
Expand Down Expand Up @@ -84,6 +85,7 @@ module.exports = {
'no-large-snapshots': noLargeSnapshots,
'no-test-prefixes': noTestPrefixes,
'no-test-return-statement': noTestReturnStatement,
'prefer-spy-on': preferSpyOn,
'prefer-to-be-null': preferToBeNull,
'prefer-to-be-undefined': preferToBeUndefined,
'prefer-to-contain': preferToContain,
Expand Down
108 changes: 108 additions & 0 deletions rules/__tests__/prefer-spy-on.test.js
@@ -0,0 +1,108 @@
'use strict';

const RuleTester = require('eslint').RuleTester;
const rule = require('../prefer-spy-on');

const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 6,
},
});

ruleTester.run('prefer-spy-on', rule, {
valid: [
'Date.now = () => 10',
'window.fetch = jest.fn',
'obj.mock = jest.something()',
'const mock = jest.fn()',
'mock = jest.fn()',
'const mockObj = { mock: jest.fn() }',
'mockObj = { mock: jest.fn() }',
'window[`${name}`] = jest[`fn${expression}`]()',
],
invalid: [
{
code: 'obj.a = jest.fn(); const test = 10;',
errors: [
{
message: 'Use jest.spyOn() instead.',
type: 'AssignmentExpression',
},
],
output: "jest.spyOn(obj, 'a'); const test = 10;",
},
{
code: "Date['now'] = jest['fn']()",
errors: [
{
message: 'Use jest.spyOn() instead.',
type: 'AssignmentExpression',
},
],
output: "jest.spyOn(Date, 'now')",
},
{
code: 'window[`${name}`] = jest[`fn`]()',
errors: [
{
message: 'Use jest.spyOn() instead.',
type: 'AssignmentExpression',
},
],
output: 'jest.spyOn(window, `${name}`)',
},
{
code: "obj['prop' + 1] = jest['fn']()",
errors: [
{
message: 'Use jest.spyOn() instead.',
type: 'AssignmentExpression',
},
],
output: "jest.spyOn(obj, 'prop' + 1)",
},
{
code: 'obj.one.two = jest.fn(); const test = 10;',
errors: [
{
message: 'Use jest.spyOn() instead.',
type: 'AssignmentExpression',
},
],
output: "jest.spyOn(obj.one, 'two'); const test = 10;",
},
{
code: 'obj.a = jest.fn(() => 10)',
errors: [
{
message: 'Use jest.spyOn() instead.',
type: 'AssignmentExpression',
},
],
output: "jest.spyOn(obj, 'a').mockImplementation(() => 10)",
},
{
code:
"obj.a.b = jest.fn(() => ({})).mockReturnValue('default').mockReturnValueOnce('first call'); test();",
errors: [
{
message: 'Use jest.spyOn() instead.',
type: 'AssignmentExpression',
},
],
output:
"jest.spyOn(obj.a, 'b').mockImplementation(() => ({})).mockReturnValue('default').mockReturnValueOnce('first call'); test();",
},
{
code: 'window.fetch = jest.fn(() => ({})).one.two().three().four',
errors: [
{
message: 'Use jest.spyOn() instead.',
type: 'AssignmentExpression',
},
],
output:
"jest.spyOn(window, 'fetch').mockImplementation(() => ({})).one.two().three().four",
},
],
});
67 changes: 67 additions & 0 deletions rules/prefer-spy-on.js
@@ -0,0 +1,67 @@
'use strict';

const getDocsUrl = require('./util').getDocsUrl;
const getNodeName = require('./util').getNodeName;

const getJestFnCall = node => {
if (node.type !== 'CallExpression' && node.type !== 'MemberExpression') {
return null;
}

const obj = node.callee ? node.callee.object : node.object;

if (obj.type === 'Identifier') {
return node.type === 'CallExpression' &&
getNodeName(node.callee) === 'jest.fn'
? node
: null;
}

return getJestFnCall(obj);
};

module.exports = {
meta: {
docs: {
url: getDocsUrl(__filename),
},
fixable: 'code',
},
create(context) {
return {
AssignmentExpression(node) {
if (node.left.type !== 'MemberExpression') return;

const jestFnCall = getJestFnCall(node.right);

if (!jestFnCall) return;

context.report({
node,
message: 'Use jest.spyOn() instead.',
fix(fixer) {
const leftPropQuote =
node.left.property.type === 'Identifier' ? "'" : '';
const arg = jestFnCall.arguments[0];
const argSource = arg && context.getSourceCode().getText(arg);
const mockImplementation = argSource
? `.mockImplementation(${argSource})`
: '';

return [
fixer.insertTextBefore(node.left, `jest.spyOn(`),
fixer.replaceTextRange(
[node.left.object.end, node.left.property.start],
`, ${leftPropQuote}`
),
fixer.replaceTextRange(
[node.left.property.end, jestFnCall.end],
`${leftPropQuote})${mockImplementation}`
),
];
},
});
},
};
},
};
3 changes: 3 additions & 0 deletions rules/util.js
Expand Up @@ -107,6 +107,9 @@ const getNodeName = node => {
return node.name;
case 'Literal':
return node.value;
case 'TemplateLiteral':
if (node.expressions.length === 0) return node.quasis[0].value.cooked;
break;
case 'MemberExpression':
return joinNames(getNodeName(node.object), getNodeName(node.property));
}
Expand Down

0 comments on commit ae7aee9

Please sign in to comment.