Skip to content

Commit 51fca42

Browse files
committed
feat: Add prefer-comparision-matcher rule
Fixes #230
1 parent 7faa278 commit 51fca42

5 files changed

+496
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ CLI option\
145145
| [no-useless-not](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-useless-not.md) | Disallow usage of `not` matchers when a specific matcher exists || 🔧 | |
146146
| [no-wait-for-selector](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-selector.md) | Disallow usage of `page.waitForSelector()` || | 💡 |
147147
| [no-wait-for-timeout](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-timeout.md) | Disallow usage of `page.waitForTimeout()` || | 💡 |
148+
| [prefer-comparison-matcher](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-comparison-matcher.md) | Suggest using the built-in comparison matchers | | 🔧 | |
148149
| [prefer-hooks-in-order](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-in-order.md) | Prefer having hooks in a consistent order | | | |
149150
| [prefer-hooks-on-top](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | | | |
150151
| [prefer-strict-equal](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-strict-equal.md) | Suggest using `toStrictEqual()` | | | 💡 |
+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Suggest using the built-in comparison matchers (`prefer-comparison-matcher`)
2+
3+
Playwright has a number of built-in matchers for comparing numbers, which allow
4+
for more readable tests and error messages if an expectation fails.
5+
6+
## Rule details
7+
8+
This rule checks for comparisons in tests that could be replaced with one of the
9+
following built-in comparison matchers:
10+
11+
- `toBeGreaterThan`
12+
- `toBeGreaterThanOrEqual`
13+
- `toBeLessThan`
14+
- `toBeLessThanOrEqual`
15+
16+
Examples of **incorrect** code for this rule:
17+
18+
```js
19+
expect(x > 5).toBe(true);
20+
expect(x < 7).not.toEqual(true);
21+
expect(x <= y).toStrictEqual(true);
22+
```
23+
24+
Examples of **correct** code for this rule:
25+
26+
```js
27+
expect(x).toBeGreaterThan(5);
28+
expect(x).not.toBeLessThanOrEqual(7);
29+
expect(x).toBeLessThanOrEqual(y);
30+
31+
// special case - see below
32+
expect(x < 'Carl').toBe(true);
33+
```
34+
35+
Note that these matchers only work with numbers and bigints, and that the rule
36+
assumes that any variables on either side of the comparison operator are of one
37+
of those types - this means if you're using the comparison operator with
38+
strings, the fix applied by this rule will result in an error.
39+
40+
```js
41+
expect(myName).toBeGreaterThanOrEqual(theirName); // Matcher error: received value must be a number or bigint
42+
```
43+
44+
The reason for this is that comparing strings with these operators is expected
45+
to be very rare and would mean not being able to have an automatic fixer for
46+
this rule.
47+
48+
If for some reason you are using these operators to compare strings, you can
49+
disable this rule using an inline
50+
[configuration comment](https://eslint.org/docs/user-guide/configuring/rules#disabling-rules):
51+
52+
```js
53+
// eslint-disable-next-line playwright/prefer-comparison-matcher
54+
expect(myName > theirName).toBe(true);
55+
```

src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import noUselessAwait from './rules/no-useless-await';
2525
import noUselessNot from './rules/no-useless-not';
2626
import noWaitForSelector from './rules/no-wait-for-selector';
2727
import noWaitForTimeout from './rules/no-wait-for-timeout';
28+
import preferComparisonMatcher from './rules/prefer-comparison-matcher';
2829
import preferHooksInOrder from './rules/prefer-hooks-in-order';
2930
import preferHooksOnTop from './rules/prefer-hooks-on-top';
3031
import preferLowercaseTitle from './rules/prefer-lowercase-title';
@@ -68,6 +69,7 @@ const index = {
6869
'no-useless-not': noUselessNot,
6970
'no-wait-for-selector': noWaitForSelector,
7071
'no-wait-for-timeout': noWaitForTimeout,
72+
'prefer-comparison-matcher': preferComparisonMatcher,
7173
'prefer-hooks-in-order': preferHooksInOrder,
7274
'prefer-hooks-on-top': preferHooksOnTop,
7375
'prefer-lowercase-title': preferLowercaseTitle,
+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Rule } from 'eslint';
2+
import * as ESTree from 'estree';
3+
import {
4+
getParent,
5+
getRawValue,
6+
getStringValue,
7+
isBooleanLiteral,
8+
isStringLiteral,
9+
} from '../utils/ast';
10+
import { parseExpectCall } from '../utils/parseExpectCall';
11+
12+
const equalityMatchers = new Set(['toBe', 'toEqual', 'toStrictEqual']);
13+
14+
const isString = (node: ESTree.Node) => {
15+
return isStringLiteral(node) || node.type === 'TemplateLiteral';
16+
};
17+
18+
const isComparingToString = (expression: ESTree.BinaryExpression) => {
19+
return isString(expression.left) || isString(expression.right);
20+
};
21+
22+
const invertedOperators: Record<string, string | undefined> = {
23+
'<': '>=',
24+
'<=': '>',
25+
'>': '<=',
26+
'>=': '<',
27+
};
28+
29+
const operatorMatcher: Record<string, string | undefined> = {
30+
'<': 'toBeLessThan',
31+
'<=': 'toBeLessThanOrEqual',
32+
'>': 'toBeGreaterThan',
33+
'>=': 'toBeGreaterThanOrEqual',
34+
};
35+
36+
const determineMatcher = (
37+
operator: string,
38+
negated: boolean,
39+
): string | null => {
40+
const op = negated ? invertedOperators[operator] : operator;
41+
return operatorMatcher[op!] ?? null;
42+
};
43+
44+
export default {
45+
create(context) {
46+
return {
47+
CallExpression(node) {
48+
const expectCall = parseExpectCall(context, node);
49+
if (!expectCall || expectCall.args.length === 0) return;
50+
51+
const { args, matcher } = expectCall;
52+
const [comparison] = node.arguments;
53+
const expectCallEnd = node.range![1];
54+
const [matcherArg] = args;
55+
56+
if (
57+
comparison?.type !== 'BinaryExpression' ||
58+
isComparingToString(comparison) ||
59+
!equalityMatchers.has(getStringValue(matcher)) ||
60+
!isBooleanLiteral(matcherArg)
61+
) {
62+
return;
63+
}
64+
65+
const hasNot = expectCall.modifiers.some(
66+
(node) => getStringValue(node) === 'not',
67+
);
68+
69+
const preferredMatcher = determineMatcher(
70+
comparison.operator,
71+
getRawValue(matcherArg) === hasNot.toString(),
72+
);
73+
74+
if (!preferredMatcher) {
75+
return;
76+
}
77+
78+
context.report({
79+
data: { preferredMatcher },
80+
fix(fixer) {
81+
// Preserve the existing modifier if it's not a negation
82+
const [modifier] = expectCall.modifiers;
83+
const modifierText =
84+
modifier && getStringValue(modifier) !== 'not'
85+
? `.${getStringValue(modifier)}`
86+
: '';
87+
88+
return [
89+
// Replace the comparison argument with the left-hand side of the comparison
90+
fixer.replaceText(
91+
comparison,
92+
context.sourceCode.getText(comparison.left),
93+
),
94+
// Replace the current matcher & modifier with the preferred matcher
95+
fixer.replaceTextRange(
96+
[expectCallEnd, getParent(matcher)!.range![1]],
97+
`${modifierText}.${preferredMatcher}`,
98+
),
99+
// Replace the matcher argument with the right-hand side of the comparison
100+
fixer.replaceText(
101+
matcherArg,
102+
context.sourceCode.getText(comparison.right),
103+
),
104+
];
105+
},
106+
messageId: 'useToBeComparison',
107+
node: matcher,
108+
});
109+
},
110+
};
111+
},
112+
meta: {
113+
docs: {
114+
category: 'Best Practices',
115+
description: 'Suggest using the built-in comparison matchers',
116+
recommended: false,
117+
},
118+
fixable: 'code',
119+
messages: {
120+
useToBeComparison: 'Prefer using `{{ preferredMatcher }}` instead',
121+
},
122+
type: 'suggestion',
123+
},
124+
} as Rule.RuleModule;

0 commit comments

Comments
 (0)