Skip to content

Commit 9778666

Browse files
committed
feat: create no-restricted-matchers rule
1 parent d7cc77e commit 9778666

File tree

6 files changed

+332
-1
lines changed

6 files changed

+332
-1
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ installations requiring long-term consistency.
147147
| [no-jest-import](docs/rules/no-jest-import.md) | Disallow importing Jest | ![recommended][] | |
148148
| [no-large-snapshots](docs/rules/no-large-snapshots.md) | disallow large snapshots | | |
149149
| [no-mocks-import](docs/rules/no-mocks-import.md) | Disallow manually importing from **mocks** | ![recommended][] | |
150+
| [no-restricted-matchers](docs/rules/no-restricted-matchers.md) | Disallow specific matchers & modifiers | | |
150151
| [no-standalone-expect](docs/rules/no-standalone-expect.md) | Prevents expects that are outside of an it or test block. | ![recommended][] | |
151152
| [no-test-callback](docs/rules/no-test-callback.md) | Avoid using a callback in asynchronous tests | ![recommended][] | ![fixable][] |
152153
| [no-test-prefixes](docs/rules/no-test-prefixes.md) | Use `.only` and `.skip` over `f` and `x` | ![recommended][] | ![fixable][] |

docs/rules/no-restricted-matchers.md

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Disallow specific matchers & modifiers (`no-restricted-matchers`)
2+
3+
This rule bans specific matchers & modifiers from being used, and can suggest
4+
alternatives.
5+
6+
## Rule Details
7+
8+
Bans are expressed in the form of a map, with the value being either a string
9+
message to be shown, or `null` if the default rule message should be used.
10+
11+
Both matchers, modifiers, and chains of the two are checked, allowing for
12+
specific variations of a matcher to be banned if desired.
13+
14+
By default, this map is empty, meaning no matchers or modifiers are banned.
15+
16+
For example:
17+
18+
```json
19+
{
20+
"jest/no-restricted-matchers": [
21+
"error",
22+
{
23+
"toBeFalsy": null,
24+
"resolves": "Use `expect(await promise)` instead.",
25+
"not.toHaveBeenCalledWith": null
26+
}
27+
]
28+
}
29+
```
30+
31+
Examples of **incorrect** code for this rule with the above configuration
32+
33+
```js
34+
it('is false', () => {
35+
expect(a).toBeFalsy();
36+
});
37+
38+
it('resolves', async () => {
39+
await expect(myPromise()).resolves.toBe(true);
40+
});
41+
42+
describe('when an error happens', () => {
43+
it('does not upload the file', async () => {
44+
expect(uploadFileMock).not.toHaveBeenCalledWith('file.name');
45+
});
46+
});
47+
```

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

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Object {
2828
"jest/no-jest-import": "error",
2929
"jest/no-large-snapshots": "error",
3030
"jest/no-mocks-import": "error",
31+
"jest/no-restricted-matchers": "error",
3132
"jest/no-standalone-expect": "error",
3233
"jest/no-test-callback": "error",
3334
"jest/no-test-prefixes": "error",

src/__tests__/rules.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { resolve } from 'path';
33
import plugin from '../';
44

55
const ruleNames = Object.keys(plugin.rules);
6-
const numberOfRules = 41;
6+
const numberOfRules = 42;
77

88
describe('rules', () => {
99
it('should have a corresponding doc for each rule', () => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { TSESLint } from '@typescript-eslint/experimental-utils';
2+
import resolveFrom from 'resolve-from';
3+
import rule from '../no-restricted-matchers';
4+
5+
const ruleTester = new TSESLint.RuleTester({
6+
parser: resolveFrom(require.resolve('eslint'), 'espree'),
7+
parserOptions: {
8+
ecmaVersion: 2017,
9+
},
10+
});
11+
12+
ruleTester.run('no-restricted-matchers', rule, {
13+
valid: [
14+
'expect(a).toHaveBeenCalled()',
15+
'expect(a).not.toHaveBeenCalled()',
16+
'expect(a).toHaveBeenCalledTimes()',
17+
'expect(a).toHaveBeenCalledWith()',
18+
'expect(a).toHaveBeenLastCalledWith()',
19+
'expect(a).toHaveBeenNthCalledWith()',
20+
'expect(a).toHaveReturned()',
21+
'expect(a).toHaveReturnedTimes()',
22+
'expect(a).toHaveReturnedWith()',
23+
'expect(a).toHaveLastReturnedWith()',
24+
'expect(a).toHaveNthReturnedWith()',
25+
'expect(a).toThrow()',
26+
'expect(a).rejects;',
27+
'expect(a);',
28+
{
29+
code: 'expect(a).resolves',
30+
options: [{ not: null }],
31+
},
32+
{
33+
code: 'expect(a).toBe(b)',
34+
options: [{ 'not.toBe': null }],
35+
},
36+
{
37+
code: 'expect(a)["toBe"](b)',
38+
options: [{ 'not.toBe': null }],
39+
},
40+
],
41+
invalid: [
42+
{
43+
code: 'expect(a).toBe(b)',
44+
options: [{ toBe: null }],
45+
errors: [
46+
{
47+
messageId: 'restrictedChain',
48+
data: {
49+
message: null,
50+
chain: 'toBe',
51+
},
52+
column: 11,
53+
line: 1,
54+
},
55+
],
56+
},
57+
{
58+
code: 'expect(a)["toBe"](b)',
59+
options: [{ toBe: null }],
60+
errors: [
61+
{
62+
messageId: 'restrictedChain',
63+
data: {
64+
message: null,
65+
chain: 'toBe',
66+
},
67+
column: 11,
68+
line: 1,
69+
},
70+
],
71+
},
72+
{
73+
code: 'expect(a).not',
74+
options: [{ not: null }],
75+
errors: [
76+
{
77+
messageId: 'restrictedChain',
78+
data: {
79+
message: null,
80+
chain: 'not',
81+
},
82+
column: 11,
83+
line: 1,
84+
},
85+
],
86+
},
87+
{
88+
code: 'expect(a).not.toBe(b)',
89+
options: [{ not: null }],
90+
errors: [
91+
{
92+
messageId: 'restrictedChain',
93+
data: {
94+
message: null,
95+
chain: 'not',
96+
},
97+
column: 11,
98+
line: 1,
99+
},
100+
],
101+
},
102+
{
103+
code: 'expect(a).not.toBe(b)',
104+
options: [{ 'not.toBe': null }],
105+
errors: [
106+
{
107+
messageId: 'restrictedChain',
108+
data: {
109+
message: null,
110+
chain: 'not.toBe',
111+
},
112+
endColumn: 19,
113+
column: 11,
114+
line: 1,
115+
},
116+
],
117+
},
118+
{
119+
code: 'expect(a).toBe(b)',
120+
options: [{ toBe: 'Prefer `toStrictEqual` instead' }],
121+
errors: [
122+
{
123+
messageId: 'restrictedChainWithMessage',
124+
data: {
125+
message: 'Prefer `toStrictEqual` instead',
126+
chain: 'toBe',
127+
},
128+
column: 11,
129+
line: 1,
130+
},
131+
],
132+
},
133+
{
134+
code: `
135+
test('some test', async () => {
136+
await expect(Promise.resolve(1)).resolves.toBe(1);
137+
});
138+
`,
139+
options: [{ resolves: 'Use `expect(await promise)` instead.' }],
140+
errors: [
141+
{
142+
messageId: 'restrictedChainWithMessage',
143+
data: {
144+
message: 'Use `expect(await promise)` instead.',
145+
chain: 'resolves',
146+
},
147+
endColumn: 52,
148+
column: 44,
149+
},
150+
],
151+
},
152+
{
153+
code: 'expect(Promise.resolve({})).rejects.toBeFalsy()',
154+
options: [{ toBeFalsy: null }],
155+
errors: [
156+
{
157+
messageId: 'restrictedChain',
158+
data: {
159+
message: null,
160+
chain: 'toBeFalsy',
161+
},
162+
endColumn: 46,
163+
column: 37,
164+
},
165+
],
166+
},
167+
{
168+
code: "expect(uploadFileMock).not.toHaveBeenCalledWith('file.name')",
169+
options: [
170+
{ 'not.toHaveBeenCalledWith': 'Use not.toHaveBeenCalled instead' },
171+
],
172+
errors: [
173+
{
174+
messageId: 'restrictedChainWithMessage',
175+
data: {
176+
message: 'Use not.toHaveBeenCalled instead',
177+
chain: 'not.toHaveBeenCalledWith',
178+
},
179+
endColumn: 48,
180+
column: 24,
181+
},
182+
],
183+
},
184+
],
185+
});

src/rules/no-restricted-matchers.ts

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { createRule, isExpectCall, parseExpectCall } from './utils';
2+
3+
export default createRule<
4+
[Record<string, string | null>],
5+
'restrictedChain' | 'restrictedChainWithMessage'
6+
>({
7+
name: __filename,
8+
meta: {
9+
docs: {
10+
category: 'Best Practices',
11+
description: 'Disallow specific matchers & modifiers',
12+
recommended: false,
13+
},
14+
type: 'suggestion',
15+
schema: [
16+
{
17+
type: 'object',
18+
additionalProperties: {
19+
type: ['string', 'null'],
20+
},
21+
},
22+
],
23+
messages: {
24+
restrictedChain: 'Use of `{{ chain }}` is disallowed',
25+
restrictedChainWithMessage: '{{ message }}',
26+
},
27+
},
28+
defaultOptions: [{}],
29+
create(context, [restrictedChains]) {
30+
return {
31+
CallExpression(node) {
32+
if (!isExpectCall(node)) {
33+
return;
34+
}
35+
36+
const { matcher, modifier } = parseExpectCall(node);
37+
38+
if (matcher) {
39+
const chain = matcher.name;
40+
41+
if (chain in restrictedChains) {
42+
const message = restrictedChains[chain];
43+
44+
context.report({
45+
messageId: message
46+
? 'restrictedChainWithMessage'
47+
: 'restrictedChain',
48+
data: { message, chain },
49+
node: matcher.node.property,
50+
});
51+
52+
return;
53+
}
54+
}
55+
56+
if (modifier) {
57+
const chain = modifier.name;
58+
59+
if (chain in restrictedChains) {
60+
const message = restrictedChains[chain];
61+
62+
context.report({
63+
messageId: message
64+
? 'restrictedChainWithMessage'
65+
: 'restrictedChain',
66+
data: { message, chain },
67+
node: modifier.node.property,
68+
});
69+
70+
return;
71+
}
72+
}
73+
74+
if (matcher && modifier) {
75+
const chain = `${modifier.name}.${matcher.name}`;
76+
77+
if (chain in restrictedChains) {
78+
const message = restrictedChains[chain];
79+
80+
context.report({
81+
messageId: message
82+
? 'restrictedChainWithMessage'
83+
: 'restrictedChain',
84+
data: { message, chain },
85+
loc: {
86+
start: modifier.node.property.loc.start,
87+
end: matcher.node.property.loc.end,
88+
},
89+
});
90+
91+
return;
92+
}
93+
}
94+
},
95+
};
96+
},
97+
});

0 commit comments

Comments
 (0)