Skip to content

Commit 7aa5cac

Browse files
committed
feat: create ban-matchers rule
1 parent 8b2568b commit 7aa5cac

File tree

6 files changed

+327
-1
lines changed

6 files changed

+327
-1
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ installations requiring long-term consistency.
127127

128128
| Rule | Description | Configurations | Fixable |
129129
| ------------------------------ | ----------------------------------------------------------------- | ---------------- | ------------------- |
130+
| [ban-matchers][] | Bans specific matchers & modifiers from being used | | |
130131
| [consistent-test-it][] | Enforce consistent test or it keyword | | ![fixable-green][] |
131132
| [expect-expect][] | Enforce assertion to be made in a test body | ![recommended][] | |
132133
| [lowercase-name][] | Disallow capitalized test names | | ![fixable-green][] |
@@ -183,6 +184,7 @@ ensure consistency and readability in jest test suites.
183184

184185
https://github.com/dangreenisrael/eslint-plugin-jest-formatting
185186

187+
[ban-matchers]: docs/rules/ban-matchers.md
186188
[consistent-test-it]: docs/rules/consistent-test-it.md
187189
[expect-expect]: docs/rules/expect-expect.md
188190
[lowercase-name]: docs/rules/lowercase-name.md

docs/rules/ban-matchers.md

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Bans specific matchers & modifiers from being used (`ban-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/ban-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
@@ -10,6 +10,7 @@ Object {
1010
"jest",
1111
],
1212
"rules": Object {
13+
"jest/ban-matchers": "error",
1314
"jest/consistent-test-it": "error",
1415
"jest/expect-expect": "error",
1516
"jest/lowercase-name": "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', () => {
+185
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 '../ban-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('ban-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: 'bannedChain',
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: 'bannedChain',
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: 'bannedChain',
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: 'bannedChain',
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: 'bannedChain',
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: 'bannedChainWithMessage',
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: 'bannedChainWithMessage',
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: 'bannedChain',
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: 'bannedChainWithMessage',
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/ban-matchers.ts

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { createRule, isExpectCall, parseExpectCall } from './utils';
2+
3+
export default createRule<
4+
[Record<string, string | null>],
5+
'bannedChain' | 'bannedChainWithMessage'
6+
>({
7+
name: __filename,
8+
meta: {
9+
docs: {
10+
category: 'Best Practices',
11+
description: 'Bans specific matchers & modifiers from being used',
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+
bannedChain: '{{ chain }} is banned, and so should not be used',
25+
bannedChainWithMessage: '{{ message }}',
26+
},
27+
},
28+
defaultOptions: [{}],
29+
create(context, [bannedChains]) {
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 bannedChains) {
42+
const message = bannedChains[chain];
43+
44+
context.report({
45+
messageId: message ? 'bannedChainWithMessage' : 'bannedChain',
46+
data: { message, chain },
47+
node: matcher.node.property,
48+
});
49+
50+
return;
51+
}
52+
}
53+
54+
if (modifier) {
55+
const chain = modifier.name;
56+
57+
if (chain in bannedChains) {
58+
const message = bannedChains[chain];
59+
60+
context.report({
61+
messageId: message ? 'bannedChainWithMessage' : 'bannedChain',
62+
data: { message, chain },
63+
node: modifier.node.property,
64+
});
65+
66+
return;
67+
}
68+
}
69+
70+
if (matcher && modifier) {
71+
const chain = `${modifier.name}.${matcher.name}`;
72+
73+
if (chain in bannedChains) {
74+
const message = bannedChains[chain];
75+
76+
context.report({
77+
messageId: message ? 'bannedChainWithMessage' : 'bannedChain',
78+
data: { message, chain },
79+
loc: {
80+
start: modifier.node.property.loc.start,
81+
end: matcher.node.property.loc.end,
82+
},
83+
});
84+
85+
return;
86+
}
87+
}
88+
},
89+
};
90+
},
91+
});

0 commit comments

Comments
 (0)