Skip to content

Commit 341353b

Browse files
authored
feat: create prefer-equality-matcher rule (#1016)
1 parent 5447f77 commit 341353b

File tree

6 files changed

+325
-1
lines changed

6 files changed

+325
-1
lines changed

Diff for: README.md

+1
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ installations requiring long-term consistency.
178178
| [no-test-return-statement](docs/rules/no-test-return-statement.md) | Disallow explicitly returning from tests | | |
179179
| [prefer-called-with](docs/rules/prefer-called-with.md) | Suggest using `toBeCalledWith()` or `toHaveBeenCalledWith()` | | |
180180
| [prefer-comparison-matcher](docs/rules/prefer-comparison-matcher.md) | Suggest using the built-in comparison matchers | | ![fixable][] |
181+
| [prefer-equality-matcher](docs/rules/prefer-equality-matcher.md) | Suggest using the built-in equality matchers | | ![suggest][] |
181182
| [prefer-expect-assertions](docs/rules/prefer-expect-assertions.md) | Suggest using `expect.assertions()` OR `expect.hasAssertions()` | | ![suggest][] |
182183
| [prefer-expect-resolves](docs/rules/prefer-expect-resolves.md) | Prefer `await expect(...).resolves` over `expect(await ...)` syntax | | ![fixable][] |
183184
| [prefer-hooks-on-top](docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | | |

Diff for: docs/rules/prefer-equality-matcher.md

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Suggest using the built-in equality matchers (`prefer-equality-matcher`)
2+
3+
Jest has built-in matchers for expecting equality which allow for more readable
4+
tests and error messages if an expectation fails.
5+
6+
## Rule details
7+
8+
This rule checks for _strict_ equality checks (`===` & `!==`) in tests that
9+
could be replaced with one of the following built-in equality matchers:
10+
11+
- `toBe`
12+
- `toEqual`
13+
- `toStrictEqual`
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```js
18+
expect(x === 5).toBe(true);
19+
expect(name === 'Carl').not.toEqual(true);
20+
expect(myObj !== thatObj).toStrictEqual(true);
21+
```
22+
23+
Examples of **correct** code for this rule:
24+
25+
```js
26+
expect(x).toBe(5);
27+
expect(name).not.toEqual('Carl');
28+
expect(myObj).toStrictEqual(thatObj);
29+
```

Diff for: src/__tests__/__snapshots__/rules.test.ts.snap

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Object {
3636
"jest/no-test-return-statement": "error",
3737
"jest/prefer-called-with": "error",
3838
"jest/prefer-comparison-matcher": "error",
39+
"jest/prefer-equality-matcher": "error",
3940
"jest/prefer-expect-assertions": "error",
4041
"jest/prefer-expect-resolves": "error",
4142
"jest/prefer-hooks-on-top": "error",

Diff for: src/__tests__/rules.test.ts

+1-1
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 = 44;
5+
const numberOfRules = 45;
66
const ruleNames = Object.keys(plugin.rules);
77
const deprecatedRules = Object.entries(plugin.rules)
88
.filter(([, rule]) => rule.meta.deprecated)

Diff for: src/rules/__tests__/prefer-equality-matcher.test.ts

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { TSESLint } from '@typescript-eslint/experimental-utils';
2+
import rule from '../prefer-equality-matcher';
3+
import { espreeParser } from './test-utils';
4+
5+
const ruleTester = new TSESLint.RuleTester({
6+
parser: espreeParser,
7+
parserOptions: {
8+
ecmaVersion: 2015,
9+
},
10+
});
11+
12+
type RuleMessages<TRuleModule extends TSESLint.RuleModule<string>> =
13+
TRuleModule extends TSESLint.RuleModule<infer TMessageIds>
14+
? TMessageIds
15+
: never;
16+
17+
type RuleSuggestionOutput = TSESLint.SuggestionOutput<
18+
RuleMessages<typeof rule>
19+
>;
20+
21+
const expectSuggestions = (
22+
output: (equalityMatcher: string) => string,
23+
): RuleSuggestionOutput[] => {
24+
return ['toBe', 'toEqual', 'toStrictEqual'].map<RuleSuggestionOutput>(
25+
equalityMatcher => ({
26+
messageId: 'suggestEqualityMatcher',
27+
data: { equalityMatcher },
28+
output: output(equalityMatcher),
29+
}),
30+
);
31+
};
32+
33+
ruleTester.run('prefer-equality-matcher: ===', rule, {
34+
valid: [
35+
'expect(a == 1).toBe(true)',
36+
'expect(1 == a).toBe(true)',
37+
'expect(a == b).toBe(true)',
38+
],
39+
invalid: [
40+
{
41+
code: 'expect(a === b).toBe(true);',
42+
errors: [
43+
{
44+
messageId: 'useEqualityMatcher',
45+
suggestions: expectSuggestions(
46+
equalityMatcher => `expect(a).${equalityMatcher}(b);`,
47+
),
48+
column: 17,
49+
line: 1,
50+
},
51+
],
52+
},
53+
{
54+
code: 'expect(a === b).toBe(false);',
55+
errors: [
56+
{
57+
messageId: 'useEqualityMatcher',
58+
suggestions: expectSuggestions(
59+
equalityMatcher => `expect(a).not.${equalityMatcher}(b);`,
60+
),
61+
column: 17,
62+
line: 1,
63+
},
64+
],
65+
},
66+
{
67+
code: 'expect(a === b).not.toBe(true);',
68+
errors: [
69+
{
70+
messageId: 'useEqualityMatcher',
71+
suggestions: expectSuggestions(
72+
equalityMatcher => `expect(a).not.${equalityMatcher}(b);`,
73+
),
74+
column: 17,
75+
line: 1,
76+
},
77+
],
78+
},
79+
{
80+
code: 'expect(a === b).not.toBe(false);',
81+
errors: [
82+
{
83+
messageId: 'useEqualityMatcher',
84+
suggestions: expectSuggestions(
85+
equalityMatcher => `expect(a).${equalityMatcher}(b);`,
86+
),
87+
column: 17,
88+
line: 1,
89+
},
90+
],
91+
},
92+
],
93+
});
94+
95+
ruleTester.run('prefer-equality-matcher: !==', rule, {
96+
valid: [
97+
'expect(a != 1).toBe(true)',
98+
'expect(1 != a).toBe(true)',
99+
'expect(a != b).toBe(true)',
100+
],
101+
invalid: [
102+
{
103+
code: 'expect(a !== b).toBe(true);',
104+
errors: [
105+
{
106+
messageId: 'useEqualityMatcher',
107+
suggestions: expectSuggestions(
108+
equalityMatcher => `expect(a).not.${equalityMatcher}(b);`,
109+
),
110+
column: 17,
111+
line: 1,
112+
},
113+
],
114+
},
115+
{
116+
code: 'expect(a !== b).toBe(false);',
117+
errors: [
118+
{
119+
messageId: 'useEqualityMatcher',
120+
suggestions: expectSuggestions(
121+
equalityMatcher => `expect(a).${equalityMatcher}(b);`,
122+
),
123+
column: 17,
124+
line: 1,
125+
},
126+
],
127+
},
128+
{
129+
code: 'expect(a !== b).not.toBe(true);',
130+
errors: [
131+
{
132+
messageId: 'useEqualityMatcher',
133+
suggestions: expectSuggestions(
134+
equalityMatcher => `expect(a).${equalityMatcher}(b);`,
135+
),
136+
column: 17,
137+
line: 1,
138+
},
139+
],
140+
},
141+
{
142+
code: 'expect(a !== b).not.toBe(false);',
143+
errors: [
144+
{
145+
messageId: 'useEqualityMatcher',
146+
suggestions: expectSuggestions(
147+
equalityMatcher => `expect(a).not.${equalityMatcher}(b);`,
148+
),
149+
column: 17,
150+
line: 1,
151+
},
152+
],
153+
},
154+
],
155+
});

Diff for: src/rules/prefer-equality-matcher.ts

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import {
2+
AST_NODE_TYPES,
3+
TSESLint,
4+
TSESTree,
5+
} from '@typescript-eslint/experimental-utils';
6+
import {
7+
MaybeTypeCast,
8+
ModifierName,
9+
ParsedEqualityMatcherCall,
10+
ParsedExpectMatcher,
11+
createRule,
12+
followTypeAssertionChain,
13+
isExpectCall,
14+
isParsedEqualityMatcherCall,
15+
parseExpectCall,
16+
} from './utils';
17+
18+
const isBooleanLiteral = (
19+
node: TSESTree.Node,
20+
): node is TSESTree.BooleanLiteral =>
21+
node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean';
22+
23+
type ParsedBooleanEqualityMatcherCall = ParsedEqualityMatcherCall<
24+
MaybeTypeCast<TSESTree.BooleanLiteral>
25+
>;
26+
27+
/**
28+
* Checks if the given `ParsedExpectMatcher` is a call to one of the equality matchers,
29+
* with a boolean literal as the sole argument.
30+
*
31+
* @example javascript
32+
* toBe(true);
33+
* toEqual(false);
34+
*
35+
* @param {ParsedExpectMatcher} matcher
36+
*
37+
* @return {matcher is ParsedBooleanEqualityMatcher}
38+
*/
39+
const isBooleanEqualityMatcher = (
40+
matcher: ParsedExpectMatcher,
41+
): matcher is ParsedBooleanEqualityMatcherCall =>
42+
isParsedEqualityMatcherCall(matcher) &&
43+
isBooleanLiteral(followTypeAssertionChain(matcher.arguments[0]));
44+
45+
export default createRule({
46+
name: __filename,
47+
meta: {
48+
docs: {
49+
category: 'Best Practices',
50+
description: 'Suggest using the built-in equality matchers',
51+
recommended: false,
52+
suggestion: true,
53+
},
54+
messages: {
55+
useEqualityMatcher: 'Prefer using one of the equality matchers instead',
56+
suggestEqualityMatcher: 'Use `{{ equalityMatcher }}`',
57+
},
58+
hasSuggestions: true,
59+
type: 'suggestion',
60+
schema: [],
61+
},
62+
defaultOptions: [],
63+
create(context) {
64+
return {
65+
CallExpression(node) {
66+
if (!isExpectCall(node)) {
67+
return;
68+
}
69+
70+
const {
71+
expect: {
72+
arguments: [comparison],
73+
range: [, expectCallEnd],
74+
},
75+
matcher,
76+
modifier,
77+
} = parseExpectCall(node);
78+
79+
if (
80+
!matcher ||
81+
comparison?.type !== AST_NODE_TYPES.BinaryExpression ||
82+
(comparison.operator !== '===' && comparison.operator !== '!==') ||
83+
!isBooleanEqualityMatcher(matcher)
84+
) {
85+
return;
86+
}
87+
88+
const matcherValue = followTypeAssertionChain(
89+
matcher.arguments[0],
90+
).value;
91+
92+
// we need to negate the expectation if the current expected
93+
// value is itself negated by the "not" modifier
94+
const addNotModifier =
95+
(comparison.operator === '!==' ? !matcherValue : matcherValue) ===
96+
!!modifier;
97+
98+
const buildFixer =
99+
(equalityMatcher: string): TSESLint.ReportFixFunction =>
100+
fixer => {
101+
const sourceCode = context.getSourceCode();
102+
103+
return [
104+
// replace the comparison argument with the left-hand side of the comparison
105+
fixer.replaceText(
106+
comparison,
107+
sourceCode.getText(comparison.left),
108+
),
109+
// replace the current matcher & modifier with the preferred matcher
110+
fixer.replaceTextRange(
111+
[expectCallEnd, matcher.node.range[1]],
112+
addNotModifier
113+
? `.${ModifierName.not}.${equalityMatcher}`
114+
: `.${equalityMatcher}`,
115+
),
116+
// replace the matcher argument with the right-hand side of the comparison
117+
fixer.replaceText(
118+
matcher.arguments[0],
119+
sourceCode.getText(comparison.right),
120+
),
121+
];
122+
};
123+
124+
context.report({
125+
messageId: 'useEqualityMatcher',
126+
suggest: ['toBe', 'toEqual', 'toStrictEqual'].map(
127+
equalityMatcher => ({
128+
messageId: 'suggestEqualityMatcher',
129+
data: { equalityMatcher },
130+
fix: buildFixer(equalityMatcher),
131+
}),
132+
),
133+
node: (modifier || matcher).node.property,
134+
});
135+
},
136+
};
137+
},
138+
});

0 commit comments

Comments
 (0)