Skip to content

Commit c484b35

Browse files
authored
Merge pull request #146 from playwright-community/prefer-to-contain
Add `prefer-to-contain` rule
2 parents b337d04 + 1507299 commit c484b35

File tree

6 files changed

+312
-0
lines changed

6 files changed

+312
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ command line option.\
7777
| | | 💡 | [prefer-strict-equal](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-strict-equal.md) | Suggest using `toStrictEqual()` |
7878
| | 🔧 | | [prefer-lowercase-title](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-lowercase-title.md) | Enforce lowercase test names |
7979
| | 🔧 | | [prefer-to-be](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-be.md) | Suggest using `toBe()` |
80+
| | 🔧 | | [prefer-to-contain](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-contain.md) | Suggest using `toContain()` |
8081
| | 🔧 | | [prefer-to-have-length](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-have-length.md) | Suggest using `toHaveLength()` |
8182
|| 🔧 | | [prefer-web-first-assertions](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-web-first-assertions.md) | Suggest using web first assertions |
8283
| | | | [require-top-level-describe](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/require-top-level-describe.md) | Require test cases and hooks to be inside a `test.describe` block |

docs/rules/prefer-to-contain.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Suggest using `toContain()` (`prefer-to-contain`)
2+
3+
In order to have a better failure message, `toContain()` should be used upon
4+
asserting expectations on an array containing an object.
5+
6+
## Rule Details
7+
8+
Example of **incorrect** code for this rule:
9+
10+
```javascript
11+
expect(a.includes(b)).toBe(true);
12+
expect(a.includes(b)).not.toBe(true);
13+
expect(a.includes(b)).toBe(false);
14+
expect(a.includes(b)).toEqual(true);
15+
expect(a.includes(b)).toStrictEqual(true);
16+
```
17+
18+
Example of **correct** code for this rule:
19+
20+
```javascript
21+
expect(a).toContain(b);
22+
expect(a).not.toContain(b);
23+
```

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import noWaitForTimeout from './rules/no-wait-for-timeout';
1818
import preferLowercaseTitle from './rules/prefer-lowercase-title';
1919
import preferStrictEqual from './rules/prefer-strict-equal';
2020
import preferToBe from './rules/prefer-to-be';
21+
import preferToContain from './rules/prefer-to-contain';
2122
import preferToHaveLength from './rules/prefer-to-have-length';
2223
import preferWebFirstAssertions from './rules/prefer-web-first-assertions';
2324
import requireSoftAssertions from './rules/require-soft-assertions';
@@ -109,6 +110,7 @@ export = {
109110
'prefer-lowercase-title': preferLowercaseTitle,
110111
'prefer-strict-equal': preferStrictEqual,
111112
'prefer-to-be': preferToBe,
113+
'prefer-to-contain': preferToContain,
112114
'prefer-to-have-length': preferToHaveLength,
113115
'prefer-web-first-assertions': preferWebFirstAssertions,
114116
'require-soft-assertions': requireSoftAssertions,

src/rules/prefer-to-contain.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Rule } from 'eslint';
2+
import * as ESTree from 'estree';
3+
import {
4+
getStringValue,
5+
isBooleanLiteral,
6+
isPropertyAccessor,
7+
} from '../utils/ast';
8+
import { parseExpectCall } from '../utils/parseExpectCall';
9+
import { KnownCallExpression } from '../utils/types';
10+
11+
const matchers = new Set(['toBe', 'toEqual', 'toStrictEqual']);
12+
13+
type FixableIncludesCallExpression = KnownCallExpression;
14+
15+
const isFixableIncludesCallExpression = (
16+
node: ESTree.Node
17+
): node is FixableIncludesCallExpression =>
18+
node.type === 'CallExpression' &&
19+
node.callee.type === 'MemberExpression' &&
20+
isPropertyAccessor(node.callee, 'includes') &&
21+
node.arguments.length === 1 &&
22+
node.arguments[0].type !== 'SpreadElement';
23+
24+
export default {
25+
create(context) {
26+
return {
27+
CallExpression(node) {
28+
const expectCall = parseExpectCall(node);
29+
if (!expectCall || expectCall.args.length === 0) return;
30+
31+
const { args, matcher, matcherName } = expectCall;
32+
const [includesCall] = node.arguments;
33+
const [matcherArg] = args;
34+
35+
if (
36+
!includesCall ||
37+
matcherArg.type === 'SpreadElement' ||
38+
!matchers.has(matcherName) ||
39+
!isBooleanLiteral(matcherArg) ||
40+
!isFixableIncludesCallExpression(includesCall)
41+
) {
42+
return;
43+
}
44+
45+
const notModifier = expectCall.modifiers.find(
46+
(node) => getStringValue(node) === 'not'
47+
);
48+
49+
context.report({
50+
fix(fixer) {
51+
const sourceCode = context.getSourceCode();
52+
53+
// We need to negate the expectation if the current expected
54+
// value is itself negated by the "not" modifier
55+
const addNotModifier =
56+
matcherArg.type === 'Literal' &&
57+
matcherArg.value === !!notModifier;
58+
59+
const fixes = [
60+
// remove the "includes" call entirely
61+
fixer.removeRange([
62+
includesCall.callee.property.range![0] - 1,
63+
includesCall.range![1],
64+
]),
65+
// replace the current matcher with "toContain", adding "not" if needed
66+
fixer.replaceText(
67+
matcher,
68+
addNotModifier ? 'not.toContain' : 'toContain'
69+
),
70+
// replace the matcher argument with the value from the "includes"
71+
fixer.replaceText(
72+
expectCall.args[0],
73+
sourceCode.getText(includesCall.arguments[0])
74+
),
75+
];
76+
77+
// Remove the "not" modifier if needed
78+
if (notModifier) {
79+
fixes.push(
80+
fixer.removeRange([
81+
notModifier.range![0],
82+
notModifier.range![1] + 1,
83+
])
84+
);
85+
}
86+
87+
return fixes;
88+
},
89+
messageId: 'useToContain',
90+
node: matcher,
91+
});
92+
},
93+
};
94+
},
95+
meta: {
96+
docs: {
97+
category: 'Best Practices',
98+
description: 'Suggest using toContain()',
99+
recommended: false,
100+
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-contain.md',
101+
},
102+
fixable: 'code',
103+
messages: {
104+
useToContain: 'Use toContain() instead',
105+
},
106+
type: 'suggestion',
107+
},
108+
} as Rule.RuleModule;

src/utils/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@ export type TypedNodeWithParent<T extends ESTree.Node['type']> = Extract<
88
{ type: T }
99
> &
1010
Rule.NodeParentExtension;
11+
12+
export type KnownCallExpression = ESTree.CallExpression & {
13+
callee: ESTree.MemberExpression;
14+
};

test/spec/prefer-to-contain.spec.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import rule from '../../src/rules/prefer-to-contain';
2+
import { runRuleTester } from '../utils/rule-tester';
3+
4+
runRuleTester('prefer-to-contain', rule, {
5+
invalid: [
6+
{
7+
code: 'expect(a.includes(b)).toEqual(true);',
8+
errors: [{ column: 23, line: 1, messageId: 'useToContain' }],
9+
output: 'expect(a).toContain(b);',
10+
},
11+
{
12+
code: 'expect(a.includes(b,),).toEqual(true,);',
13+
errors: [{ column: 25, line: 1, messageId: 'useToContain' }],
14+
output: 'expect(a,).toContain(b,);',
15+
},
16+
{
17+
code: "expect(a['includes'](b)).toEqual(true);",
18+
errors: [{ column: 26, line: 1, messageId: 'useToContain' }],
19+
output: 'expect(a).toContain(b);',
20+
},
21+
{
22+
code: "expect(a['includes'](b)).toEqual(false);",
23+
errors: [{ column: 26, line: 1, messageId: 'useToContain' }],
24+
output: 'expect(a).not.toContain(b);',
25+
},
26+
{
27+
code: "expect(a['includes'](b)).not.toEqual(false);",
28+
errors: [{ column: 30, line: 1, messageId: 'useToContain' }],
29+
output: 'expect(a).toContain(b);',
30+
},
31+
{
32+
code: 'expect(a.includes(b)).toEqual(false);',
33+
errors: [{ column: 23, line: 1, messageId: 'useToContain' }],
34+
output: 'expect(a).not.toContain(b);',
35+
},
36+
{
37+
code: 'expect(a.includes(b)).not.toEqual(false);',
38+
errors: [{ column: 27, line: 1, messageId: 'useToContain' }],
39+
output: 'expect(a).toContain(b);',
40+
},
41+
{
42+
code: 'expect(a.includes(b)).not.toEqual(true);',
43+
errors: [{ column: 27, line: 1, messageId: 'useToContain' }],
44+
output: 'expect(a).not.toContain(b);',
45+
},
46+
{
47+
code: 'expect(a.includes(b)).toBe(true);',
48+
errors: [{ column: 23, line: 1, messageId: 'useToContain' }],
49+
output: 'expect(a).toContain(b);',
50+
},
51+
{
52+
code: 'expect(a.includes(b)).toBe(false);',
53+
errors: [{ column: 23, line: 1, messageId: 'useToContain' }],
54+
output: 'expect(a).not.toContain(b);',
55+
},
56+
{
57+
code: 'expect(a.includes(b)).not.toBe(false);',
58+
errors: [{ column: 27, line: 1, messageId: 'useToContain' }],
59+
output: 'expect(a).toContain(b);',
60+
},
61+
{
62+
code: 'expect(a.includes(b)).not.toBe(true);',
63+
errors: [{ column: 27, line: 1, messageId: 'useToContain' }],
64+
output: 'expect(a).not.toContain(b);',
65+
},
66+
{
67+
code: 'expect(a.includes(b)).toStrictEqual(true);',
68+
errors: [{ column: 23, line: 1, messageId: 'useToContain' }],
69+
output: 'expect(a).toContain(b);',
70+
},
71+
{
72+
code: 'expect(a.includes(b)).toStrictEqual(false);',
73+
errors: [{ column: 23, line: 1, messageId: 'useToContain' }],
74+
output: 'expect(a).not.toContain(b);',
75+
},
76+
{
77+
code: 'expect(a.includes(b)).not.toStrictEqual(false);',
78+
errors: [{ column: 27, line: 1, messageId: 'useToContain' }],
79+
output: 'expect(a).toContain(b);',
80+
},
81+
{
82+
code: 'expect(a.includes(b)).not.toStrictEqual(true);',
83+
errors: [{ column: 27, line: 1, messageId: 'useToContain' }],
84+
output: 'expect(a).not.toContain(b);',
85+
},
86+
{
87+
code: 'expect(a.test(t).includes(b.test(p))).toEqual(true);',
88+
errors: [{ column: 39, line: 1, messageId: 'useToContain' }],
89+
output: 'expect(a.test(t)).toContain(b.test(p));',
90+
},
91+
{
92+
code: 'expect(a.test(t).includes(b.test(p))).toEqual(false);',
93+
errors: [{ column: 39, line: 1, messageId: 'useToContain' }],
94+
output: 'expect(a.test(t)).not.toContain(b.test(p));',
95+
},
96+
{
97+
code: 'expect(a.test(t).includes(b.test(p))).not.toEqual(true);',
98+
errors: [{ column: 43, line: 1, messageId: 'useToContain' }],
99+
output: 'expect(a.test(t)).not.toContain(b.test(p));',
100+
},
101+
{
102+
code: 'expect(a.test(t).includes(b.test(p))).not.toEqual(false);',
103+
errors: [{ column: 43, line: 1, messageId: 'useToContain' }],
104+
output: 'expect(a.test(t)).toContain(b.test(p));',
105+
},
106+
{
107+
code: 'expect([{a:1}].includes({a:1})).toBe(true);',
108+
errors: [{ column: 33, line: 1, messageId: 'useToContain' }],
109+
output: 'expect([{a:1}]).toContain({a:1});',
110+
},
111+
{
112+
code: 'expect([{a:1}].includes({a:1})).toBe(false);',
113+
errors: [{ column: 33, line: 1, messageId: 'useToContain' }],
114+
output: 'expect([{a:1}]).not.toContain({a:1});',
115+
},
116+
{
117+
code: 'expect([{a:1}].includes({a:1})).not.toBe(true);',
118+
errors: [{ column: 37, line: 1, messageId: 'useToContain' }],
119+
output: 'expect([{a:1}]).not.toContain({a:1});',
120+
},
121+
{
122+
code: 'expect([{a:1}].includes({a:1})).not.toBe(false);',
123+
errors: [{ column: 37, line: 1, messageId: 'useToContain' }],
124+
output: 'expect([{a:1}]).toContain({a:1});',
125+
},
126+
{
127+
code: 'expect([{a:1}].includes({a:1})).toStrictEqual(true);',
128+
errors: [{ column: 33, line: 1, messageId: 'useToContain' }],
129+
output: 'expect([{a:1}]).toContain({a:1});',
130+
},
131+
{
132+
code: 'expect([{a:1}].includes({a:1})).toStrictEqual(false);',
133+
errors: [{ column: 33, line: 1, messageId: 'useToContain' }],
134+
output: 'expect([{a:1}]).not.toContain({a:1});',
135+
},
136+
{
137+
code: 'expect([{a:1}].includes({a:1})).not.toStrictEqual(true);',
138+
errors: [{ column: 37, line: 1, messageId: 'useToContain' }],
139+
output: 'expect([{a:1}]).not.toContain({a:1});',
140+
},
141+
{
142+
code: 'expect([{a:1}].includes({a:1})).not.toStrictEqual(false);',
143+
errors: [{ column: 37, line: 1, messageId: 'useToContain' }],
144+
output: 'expect([{a:1}]).toContain({a:1});',
145+
},
146+
],
147+
valid: [
148+
'expect.hasAssertions',
149+
'expect.hasAssertions()',
150+
'expect.assertions(1)',
151+
'expect().toBe(false);',
152+
'expect(a).toContain(b);',
153+
"expect(a.name).toBe('b');",
154+
'expect(a).toBe(true);',
155+
`expect(a).toEqual(b)`,
156+
`expect(a.test(c)).toEqual(b)`,
157+
`expect(a.includes(b)).toEqual()`,
158+
`expect(a.includes(b)).toEqual("test")`,
159+
`expect(a.includes(b)).toBe("test")`,
160+
`expect(a.includes()).toEqual()`,
161+
`expect(a.includes()).toEqual(true)`,
162+
`expect(a.includes(b,c)).toBe(true)`,
163+
`expect([{a:1}]).toContain({a:1})`,
164+
`expect([1].includes(1)).toEqual`,
165+
`expect([1].includes).toEqual`,
166+
`expect([1].includes).not`,
167+
`expect(a.test(b)).resolves.toEqual(true)`,
168+
`expect(a.test(b)).resolves.not.toEqual(true)`,
169+
`expect(a).not.toContain(b)`,
170+
'expect(a.includes(...[])).toBe(true)',
171+
'expect(a.includes(b)).toBe(...true)',
172+
'expect(a);',
173+
],
174+
});

0 commit comments

Comments
 (0)