Skip to content

Commit 8aa75d2

Browse files
authored
feat: Add no-standalone-expect rule (#223)
1 parent a316e1e commit 8aa75d2

File tree

6 files changed

+300
-6
lines changed

6 files changed

+300
-6
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ command line option.\
138138
|| 🔧 | | [no-useless-await](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-useless-await.md) | Disallow unnecessary `await`s for Playwright methods |
139139
| | | | [no-restricted-matchers](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-restricted-matchers.md) | Disallow specific matchers & modifiers |
140140
|| | 💡 | [no-skipped-test](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-skipped-test.md) | Disallow usage of the `.skip` annotation |
141+
|| | | [no-standalone-expect](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-standalone-expect.md) | Disallow using expect outside of `test` blocks |
141142
|| 🔧 | | [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 |
142143
|| | 💡 | [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()` |
143144
|| | 💡 | [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()` |

docs/rules/no-standalone-expect.md

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Disallow using `expect` outside of `test` blocks (`no-standalone-expect`)
2+
3+
Prevents `expect` statements outside of a `test` block. An `expect` within a
4+
helper function (but outside of a `test` block) will not trigger this rule.
5+
6+
## Rule details
7+
8+
This rule aims to eliminate `expect` statements that will not be executed. An
9+
`expect` inside of a `describe` block but outside of a `test` block or outside a
10+
`test.describe` will not execute and therefore will trigger this rule. It is
11+
viable, however, to have an `expect` in a helper function that is called from
12+
within a `test` block so `expect` statements in a function will not trigger this
13+
rule.
14+
15+
Statements like `expect.hasAssertions()` will NOT trigger this rule since these
16+
calls will execute if they are not in a test block.
17+
18+
Examples of **incorrect** code for this rule:
19+
20+
```js
21+
// in describe
22+
test.describe('a test', () => {
23+
expect(1).toBe(1);
24+
});
25+
26+
// below other tests
27+
test.describe('a test', () => {
28+
test('an it', () => {
29+
expect(1).toBe(1);
30+
});
31+
32+
expect(1).toBe(1);
33+
});
34+
```
35+
36+
Examples of **correct** code for this rule:
37+
38+
```js
39+
// in it block
40+
test.describe('a test', () => {
41+
test('an it', () => {
42+
expect(1).toBe(1);
43+
});
44+
});
45+
46+
// in helper function
47+
test.describe('a test', () => {
48+
const helper = () => {
49+
expect(1).toBe(1);
50+
};
51+
52+
test('an it', () => {
53+
helper();
54+
});
55+
});
56+
57+
test.describe('a test', () => {
58+
expect.hasAssertions(1);
59+
});
60+
```
61+
62+
_Note that this rule will not trigger if the helper function is never used even
63+
though the `expect` will not execute. Rely on a rule like no-unused-vars for
64+
this case._
65+
66+
## When Not To Use It
67+
68+
Don't use this rule on non-playwright test files.

src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import noPagePause from './rules/no-page-pause';
1616
import noRawLocators from './rules/no-raw-locators';
1717
import noRestrictedMatchers from './rules/no-restricted-matchers';
1818
import noSkippedTest from './rules/no-skipped-test';
19+
import noStandaloneExpect from './rules/no-standalone-expect';
1920
import noUnsafeReferences from './rules/no-unsafe-references';
2021
import noUselessAwait from './rules/no-useless-await';
2122
import noUselessNot from './rules/no-useless-not';
@@ -53,6 +54,7 @@ const index = {
5354
'no-raw-locators': noRawLocators,
5455
'no-restricted-matchers': noRestrictedMatchers,
5556
'no-skipped-test': noSkippedTest,
57+
'no-standalone-expect': noStandaloneExpect,
5658
'no-unsafe-references': noUnsafeReferences,
5759
'no-useless-await': noUselessAwait,
5860
'no-useless-not': noUselessNot,
@@ -87,6 +89,7 @@ const sharedConfig = {
8789
'playwright/no-networkidle': 'error',
8890
'playwright/no-page-pause': 'warn',
8991
'playwright/no-skipped-test': 'warn',
92+
'playwright/no-standalone-expect': 'error',
9093
'playwright/no-unsafe-references': 'error',
9194
'playwright/no-useless-await': 'warn',
9295
'playwright/no-useless-not': 'warn',

src/rules/no-standalone-expect.ts

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Rule } from 'eslint';
2+
import * as ESTree from 'estree';
3+
import {
4+
findParent,
5+
getExpectType,
6+
getParent,
7+
getStringValue,
8+
isDescribeCall,
9+
isFunction,
10+
isIdentifier,
11+
isTestCall,
12+
} from '../utils/ast';
13+
14+
const getBlockType = (
15+
statement: ESTree.BlockStatement,
16+
): 'function' | 'describe' | null => {
17+
const func = getParent(statement);
18+
19+
if (!func) {
20+
throw new Error(
21+
`Unexpected BlockStatement. No parent defined. - please file a github issue at https://github.com/jest-community/eslint-plugin-jest`,
22+
);
23+
}
24+
25+
// functionDeclaration: function func() {}
26+
if (func.type === 'FunctionDeclaration') {
27+
return 'function';
28+
}
29+
30+
if (isFunction(func) && func.parent) {
31+
const expr = func.parent;
32+
33+
// arrow function or function expr
34+
if (expr.type === 'VariableDeclarator') {
35+
return 'function';
36+
}
37+
38+
// if it's not a variable, it will be callExpr, we only care about describe
39+
if (expr.type === 'CallExpression' && isDescribeCall(expr)) {
40+
return 'describe';
41+
}
42+
}
43+
44+
return null;
45+
};
46+
47+
type BlockType = 'arrow' | 'describe' | 'function' | 'template' | 'test';
48+
49+
export default {
50+
create(context: Rule.RuleContext) {
51+
const callStack: BlockType[] = [];
52+
53+
return {
54+
ArrowFunctionExpression(node) {
55+
if (node.parent?.type !== 'CallExpression') {
56+
callStack.push('arrow');
57+
}
58+
},
59+
'ArrowFunctionExpression:exit'() {
60+
if (callStack[callStack.length - 1] === 'arrow') {
61+
callStack.pop();
62+
}
63+
},
64+
65+
BlockStatement(statement) {
66+
const blockType = getBlockType(statement);
67+
68+
if (blockType) {
69+
callStack.push(blockType);
70+
}
71+
},
72+
'BlockStatement:exit'(statement: ESTree.BlockStatement) {
73+
if (callStack[callStack.length - 1] === getBlockType(statement)) {
74+
callStack.pop();
75+
}
76+
},
77+
78+
CallExpression(node) {
79+
if (getExpectType(context, node)) {
80+
const parent = callStack.at(-1);
81+
82+
if (!parent || parent === 'describe') {
83+
const root = findParent(node, 'CallExpression');
84+
context.report({
85+
messageId: 'unexpectedExpect',
86+
node: root ?? node,
87+
});
88+
}
89+
90+
return;
91+
}
92+
93+
if (isTestCall(context, node)) {
94+
callStack.push('test');
95+
}
96+
97+
if (node.callee.type === 'TaggedTemplateExpression') {
98+
callStack.push('template');
99+
}
100+
},
101+
'CallExpression:exit'(node: ESTree.CallExpression) {
102+
const top = callStack[callStack.length - 1];
103+
104+
if (
105+
(top === 'test' &&
106+
isTestCall(context, node) &&
107+
node.callee.type !== 'MemberExpression') ||
108+
(top === 'template' &&
109+
node.callee.type === 'TaggedTemplateExpression')
110+
) {
111+
callStack.pop();
112+
}
113+
},
114+
};
115+
},
116+
meta: {
117+
docs: {
118+
category: 'Best Practices',
119+
description: 'Disallow using `expect` outside of `test` blocks',
120+
recommended: false,
121+
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-contain.md',
122+
},
123+
fixable: 'code',
124+
messages: {
125+
unexpectedExpect: 'Expect must be inside of a test block',
126+
},
127+
type: 'suggestion',
128+
},
129+
} as Rule.RuleModule;

src/utils/ast.ts

+8-6
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,7 @@ export function isTestCall(
132132
!modifiers ||
133133
modifiers?.includes(getStringValue(node.callee.property))) &&
134134
node.arguments.length === 2 &&
135-
['ArrowFunctionExpression', 'FunctionExpression'].includes(
136-
node.arguments[1].type,
137-
)
135+
isFunction(node.arguments[1])
138136
);
139137
}
140138

@@ -218,10 +216,14 @@ export function isPageMethod(node: ESTree.CallExpression, name: string) {
218216
);
219217
}
220218

219+
export type FunctionExpression = (
220+
| ESTree.ArrowFunctionExpression
221+
| ESTree.FunctionExpression
222+
) &
223+
Rule.NodeParentExtension;
224+
221225
/** Returns a boolean to indicate if the node is a function or arrow function */
222-
export function isFunction(
223-
node: ESTree.Node,
224-
): node is ESTree.FunctionExpression | ESTree.ArrowFunctionExpression {
226+
export function isFunction(node: ESTree.Node): node is FunctionExpression {
225227
return (
226228
node.type === 'ArrowFunctionExpression' ||
227229
node.type === 'FunctionExpression'
+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import dedent from 'dedent';
2+
import rule from '../../src/rules/no-standalone-expect';
3+
import { runRuleTester } from '../utils/rule-tester';
4+
5+
const messageId = 'unexpectedExpect';
6+
7+
runRuleTester('no-standalone-expect', rule, {
8+
invalid: [
9+
{
10+
code: "(() => {})('testing', () => expect(true).toBe(false))",
11+
errors: [{ column: 29, endColumn: 53, messageId }],
12+
},
13+
{
14+
code: dedent`
15+
test.describe('scenario', () => {
16+
const t = Math.random() ? test.only : test;
17+
t('testing', () => expect(true).toBe(false));
18+
});
19+
`,
20+
errors: [{ column: 22, endColumn: 46, messageId }],
21+
},
22+
{
23+
code: dedent`
24+
it.describe('scenario', () => {
25+
it('testing', () => expect(true).toBe(false));
26+
});
27+
`,
28+
errors: [{ column: 23, endColumn: 47, messageId }],
29+
},
30+
{
31+
code: 'test.describe("a test", () => { expect(1).toBe(1); });',
32+
errors: [{ column: 33, endColumn: 50, messageId }],
33+
},
34+
{
35+
code: 'test.describe("a test", () => expect(6).toBe(1));',
36+
errors: [{ column: 31, endColumn: 48, messageId }],
37+
},
38+
{
39+
code: 'test.describe("a test", () => { const func = () => { expect(6).toBe(1); }; expect(1).toBe(1); });',
40+
errors: [{ column: 76, endColumn: 93, messageId }],
41+
},
42+
{
43+
code: 'test.describe("a test", () => { test("foo", () => { expect(1).toBe(1); }); expect(1).toBe(1); });',
44+
errors: [{ column: 77, endColumn: 94, messageId }],
45+
},
46+
{
47+
code: 'expect(1).toBe(1);',
48+
errors: [{ column: 1, endColumn: 18, messageId }],
49+
},
50+
{
51+
code: '{expect(1).toBe(1)}',
52+
errors: [{ column: 2, endColumn: 19, messageId }],
53+
},
54+
{
55+
code: dedent`
56+
import { expect as pleaseExpect } from '@playwright/test';
57+
test.describe("a test", () => { pleaseExpect(1).toBe(1); });
58+
`,
59+
errors: [{ column: 33, endColumn: 56, messageId }],
60+
parserOptions: { sourceType: 'module' },
61+
},
62+
],
63+
valid: [
64+
'expect.any(String)',
65+
'expect.extend({})',
66+
'test.describe("a test", () => { test("an it", () => {expect(1).toBe(1); }); });',
67+
'test.describe("a test", () => { test("an it", () => { const func = () => { expect(1).toBe(1); }; }); });',
68+
'test.describe("a test", () => { const func = () => { expect(1).toBe(1); }; });',
69+
'test.describe("a test", () => { function func() { expect(1).toBe(1); }; });',
70+
'test.describe("a test", () => { const func = function(){ expect(1).toBe(1); }; });',
71+
'test("an it", () => expect(1).toBe(1))',
72+
'const func = function(){ expect(1).toBe(1); };',
73+
'const func = () => expect(1).toBe(1);',
74+
'{}',
75+
'test.only("an only", value => { expect(value).toBe(true); });',
76+
'test.concurrent("an concurrent", value => { expect(value).toBe(true); });',
77+
// Global aliases
78+
{
79+
code: dedent`
80+
it.describe('scenario', () => {
81+
it('testing', () => expect(true));
82+
});
83+
`,
84+
settings: {
85+
playwright: {
86+
globalAliases: { test: ['it'] },
87+
},
88+
},
89+
},
90+
],
91+
});

0 commit comments

Comments
 (0)