Skip to content

Commit 281085a

Browse files
committed
feat: create new prefer-to-have-been-called-times rule
1 parent 2646599 commit 281085a

File tree

6 files changed

+190
-1
lines changed

6 files changed

+190
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ Manually fixable by
378378
| [prefer-strict-equal](docs/rules/prefer-strict-equal.md) | Suggest using `toStrictEqual()` | | | | 💡 |
379379
| [prefer-to-be](docs/rules/prefer-to-be.md) | Suggest using `toBe()` for primitive literals | 🎨 | | 🔧 | |
380380
| [prefer-to-contain](docs/rules/prefer-to-contain.md) | Suggest using `toContain()` | 🎨 | | 🔧 | |
381+
| [prefer-to-have-been-called-times](docs/rules/prefer-to-have-been-called-times.md) | Suggest using `toHaveBeenCalledTimes()` | | | 🔧 | |
381382
| [prefer-to-have-length](docs/rules/prefer-to-have-length.md) | Suggest using `toHaveLength()` | 🎨 | | 🔧 | |
382383
| [prefer-todo](docs/rules/prefer-todo.md) | Suggest using `test.todo` | | | 🔧 | |
383384
| [require-hook](docs/rules/require-hook.md) | Require setup and teardown code to be within a hook | | | | |
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Suggest using `toHaveBeenCalledTimes()` (`prefer-to-have-been-called-times`)
2+
3+
🔧 This rule is automatically fixable by the
4+
[`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
5+
6+
<!-- end auto-generated rule header -->
7+
8+
In order to have a better failure message, `toHaveBeenCalledTimes` should be
9+
used instead of directly checking the length of `mock.calls`.
10+
11+
## Rule details
12+
13+
This rule triggers a warning if `toHaveLength` is used to assert the number of
14+
times a mock is called.
15+
16+
> [!NOTE]
17+
>
18+
> This rule should ideally be paired with
19+
> [`prefer-to-have-length`](./prefer-to-have-length.md)
20+
21+
The following patterns are considered warnings:
22+
23+
```js
24+
expect(someFunction.mock.calls).toHaveLength(1);
25+
expect(someFunction.mock.calls).toHaveLength(0);
26+
27+
expect(someFunction.mock.calls).not.toHaveLength(1);
28+
```
29+
30+
The following patterns are not warnings:
31+
32+
```js
33+
expect(someFunction).toHaveBeenCalledTimes(1);
34+
expect(someFunction).toHaveBeenCalledTimes(0);
35+
36+
expect(someFunction).not.toHaveBeenCalledTimes(0);
37+
38+
expect(uncalledFunction).not.toBeCalled();
39+
40+
expect(method.mock.calls[0][0]).toStrictEqual(value);
41+
```

src/__tests__/__snapshots__/rules.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
6363
"jest/prefer-strict-equal": "error",
6464
"jest/prefer-to-be": "error",
6565
"jest/prefer-to-contain": "error",
66+
"jest/prefer-to-have-been-called-times": "error",
6667
"jest/prefer-to-have-length": "error",
6768
"jest/prefer-todo": "error",
6869
"jest/require-hook": "error",
@@ -156,6 +157,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
156157
"jest/prefer-strict-equal": "error",
157158
"jest/prefer-to-be": "error",
158159
"jest/prefer-to-contain": "error",
160+
"jest/prefer-to-have-been-called-times": "error",
159161
"jest/prefer-to-have-length": "error",
160162
"jest/prefer-todo": "error",
161163
"jest/require-hook": "error",

src/__tests__/rules.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
22
import { resolve } from 'path';
33
import plugin from '../';
44

5-
const numberOfRules = 64;
5+
const numberOfRules = 65;
66
const ruleNames = Object.keys(plugin.rules);
77
const deprecatedRules = Object.entries(plugin.rules)
88
.filter(([, rule]) => rule.meta.deprecated)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import rule from '../prefer-to-have-been-called-times';
2+
import { FlatCompatRuleTester } from './test-utils';
3+
4+
const ruleTester = new FlatCompatRuleTester();
5+
6+
ruleTester.run('prefer-to-have-been-called-times', rule, {
7+
valid: [
8+
'expect.assertions(1)',
9+
'expect(fn).toHaveBeenCalledTimes',
10+
'expect(fn.mock.calls).toHaveLength',
11+
'expect(fn.mock.values).toHaveLength(0)',
12+
'expect(fn.values.calls).toHaveLength(0)',
13+
'expect(fn).toHaveBeenCalledTimes(0)',
14+
'expect(fn).resolves.toHaveBeenCalledTimes(10)',
15+
'expect(fn).not.toHaveBeenCalledTimes(10)',
16+
'expect(fn).toHaveBeenCalledTimes(1)',
17+
'expect(fn).toBeCalledTimes(0);',
18+
'expect(fn).toHaveBeenCalledTimes(0);',
19+
'expect(fn);',
20+
'expect(method.mock.calls[0][0]).toStrictEqual(value);',
21+
],
22+
23+
invalid: [
24+
{
25+
code: 'expect(method.mock.calls).toHaveLength(1);',
26+
output: 'expect(method).toHaveBeenCalledTimes(1);',
27+
errors: [
28+
{
29+
messageId: 'preferMatcher',
30+
column: 27,
31+
line: 1,
32+
},
33+
],
34+
},
35+
{
36+
code: 'expect(method.mock.calls).resolves.toHaveLength(x);',
37+
output: 'expect(method).resolves.toHaveBeenCalledTimes(x);',
38+
errors: [
39+
{
40+
messageId: 'preferMatcher',
41+
column: 36,
42+
line: 1,
43+
},
44+
],
45+
},
46+
{
47+
code: 'expect(method["mock"].calls).toHaveLength(0);',
48+
output: 'expect(method).toHaveBeenCalledTimes(0);',
49+
errors: [
50+
{
51+
messageId: 'preferMatcher',
52+
column: 30,
53+
line: 1,
54+
},
55+
],
56+
},
57+
{
58+
code: 'expect(my.method.mock.calls).not.toHaveLength(0);',
59+
output: 'expect(my.method).not.toHaveBeenCalledTimes(0);',
60+
errors: [
61+
{
62+
messageId: 'preferMatcher',
63+
column: 34,
64+
line: 1,
65+
},
66+
],
67+
},
68+
],
69+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2+
import { createRule, isSupportedAccessor, parseJestFnCall } from './utils';
3+
4+
export default createRule({
5+
name: __filename,
6+
meta: {
7+
fixable: 'code',
8+
docs: {
9+
description: 'Suggest using `toHaveBeenCalledTimes()`',
10+
},
11+
messages: {
12+
preferMatcher: 'Prefer `toHaveBeenCalledTimes`',
13+
},
14+
type: 'suggestion',
15+
schema: [],
16+
},
17+
defaultOptions: [],
18+
create(context) {
19+
return {
20+
CallExpression(node) {
21+
const jestFnCall = parseJestFnCall(node, context);
22+
23+
if (jestFnCall?.type !== 'expect') {
24+
return;
25+
}
26+
27+
const { parent: expect } = jestFnCall.head.node;
28+
29+
if (expect?.type !== AST_NODE_TYPES.CallExpression) {
30+
return;
31+
}
32+
33+
const [argument] = expect.arguments;
34+
35+
// check if the last property in the chain is `calls`
36+
if (
37+
argument?.type !== AST_NODE_TYPES.MemberExpression ||
38+
!isSupportedAccessor(argument.property, 'calls')
39+
) {
40+
return;
41+
}
42+
43+
const { object } = argument;
44+
45+
// check if the second-to-last property in the chain is `mock`
46+
if (
47+
object.type !== AST_NODE_TYPES.MemberExpression ||
48+
!isSupportedAccessor(object.property, 'mock')
49+
) {
50+
return;
51+
}
52+
53+
const { matcher } = jestFnCall;
54+
55+
context.report({
56+
messageId: 'preferMatcher',
57+
node: matcher,
58+
fix(fixer) {
59+
return [
60+
// remove the "mock.calls" accessor chain
61+
fixer.removeRange([
62+
object.property.range[0] - 1,
63+
argument.range[1],
64+
]),
65+
// replace the current matcher with "toHaveBeenCalledTimes"
66+
fixer.replaceTextRange(
67+
[matcher.parent.object.range[1], matcher.parent.range[1]],
68+
'.toHaveBeenCalledTimes',
69+
),
70+
];
71+
},
72+
});
73+
},
74+
};
75+
},
76+
});

0 commit comments

Comments
 (0)