Skip to content

Commit 9dd834b

Browse files
authored
Merge pull request #226 from playwright-community/no-conditional-expect
feat: Add `no-conditional-expect` rule
2 parents 0a02e06 + dbf91cc commit 9dd834b

File tree

5 files changed

+742
-0
lines changed

5 files changed

+742
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ command line option.\
123123
|| | | [max-nested-describe](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/max-nested-describe.md) | Enforces a maximum depth to nested describe calls |
124124
|| 🔧 | | [missing-playwright-await](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/missing-playwright-await.md) | Enforce Playwright APIs to be awaited |
125125
| | | | [no-commented-out-test](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-commented-out-test.md) | Disallow commented out tests |
126+
|| | | [no-conditional-expect](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-expect.md) | Disallow calling `expect` conditionally |
126127
|| | | [no-conditional-in-test](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-in-test.md) | Disallow conditional logic in tests |
127128
|| | 💡 | [no-element-handle](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-element-handle.md) | Disallow usage of element handles |
128129
|| | | [no-eval](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-eval.md) | Disallow usage of `page.$eval()` and `page.$$eval()` |

docs/rules/no-conditional-expect.md

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Disallow calling `expect` conditionally (`no-conditional-expect`)
2+
3+
This rule prevents the use of `expect` in conditional blocks, such as `if`s &
4+
`catch`s.
5+
6+
This includes using `expect` in callbacks to functions named `catch`, which are
7+
assumed to be promises.
8+
9+
## Rule details
10+
11+
Playwright only considers a test to have failed if it throws an error, meaning
12+
if calls to assertion functions like `expect` occur in conditional code such as
13+
a `catch` statement, tests can end up passing but not actually test anything.
14+
15+
Additionally, conditionals tend to make tests more brittle and complex, as they
16+
increase the amount of mental thinking needed to understand what is actually
17+
being tested.
18+
19+
While `expect.assertions` & `expect.hasAssertions` can help prevent tests from
20+
silently being skipped, when combined with conditionals they typically result in
21+
even more complexity being introduced.
22+
23+
The following patterns are warnings:
24+
25+
```js
26+
test('foo', () => {
27+
doTest && expect(1).toBe(2);
28+
});
29+
30+
test('bar', () => {
31+
if (!skipTest) {
32+
expect(1).toEqual(2);
33+
}
34+
});
35+
36+
test('baz', async () => {
37+
try {
38+
await foo();
39+
} catch (err) {
40+
expect(err).toMatchObject({ code: 'MODULE_NOT_FOUND' });
41+
}
42+
});
43+
44+
test('throws an error', async () => {
45+
await foo().catch((error) => expect(error).toBeInstanceOf(error));
46+
});
47+
```
48+
49+
The following patterns are not warnings:
50+
51+
```js
52+
test('foo', () => {
53+
expect(!value).toBe(false);
54+
});
55+
56+
function getValue() {
57+
if (process.env.FAIL) {
58+
return 1;
59+
}
60+
61+
return 2;
62+
}
63+
64+
test('foo', () => {
65+
expect(getValue()).toBe(2);
66+
});
67+
68+
test('validates the request', () => {
69+
try {
70+
processRequest(request);
71+
} catch {
72+
// ignore errors
73+
} finally {
74+
expect(validRequest).toHaveBeenCalledWith(request);
75+
}
76+
});
77+
78+
test('throws an error', async () => {
79+
await expect(foo).rejects.toThrow(Error);
80+
});
81+
```
82+
83+
### How to catch a thrown error for testing without violating this rule
84+
85+
A common situation that comes up with this rule is when wanting to test
86+
properties on a thrown error, as Playwright's `toThrow` matcher only checks the
87+
`message` property.
88+
89+
Most people write something like this:
90+
91+
```typescript
92+
test.describe('when the http request fails', () => {
93+
test('includes the status code in the error', async () => {
94+
try {
95+
await makeRequest(url);
96+
} catch (error) {
97+
expect(error).toHaveProperty('statusCode', 404);
98+
}
99+
});
100+
});
101+
```
102+
103+
As stated above, the problem with this is that if `makeRequest()` doesn't throw
104+
the test will still pass as if the `expect` had been called.
105+
106+
While you can use `expect.assertions` & `expect.hasAssertions` for these
107+
situations, they only work with `expect`.
108+
109+
A better way to handle this situation is to introduce a wrapper to handle the
110+
catching, and otherwise return a specific "no error thrown" error if nothing is
111+
thrown by the wrapped function:
112+
113+
```typescript
114+
class NoErrorThrownError extends Error {}
115+
116+
const getError = async <TError>(call: () => unknown): Promise<TError> => {
117+
try {
118+
await call();
119+
120+
throw new NoErrorThrownError();
121+
} catch (error: unknown) {
122+
return error as TError;
123+
}
124+
};
125+
126+
test.describe('when the http request fails', () => {
127+
test('includes the status code in the error', async () => {
128+
const error = await getError(async () => makeRequest(url));
129+
130+
// check that the returned error wasn't that no error was thrown
131+
expect(error).not.toBeInstanceOf(NoErrorThrownError);
132+
expect(error).toHaveProperty('statusCode', 404);
133+
});
134+
});
135+
```

src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import expectExpect from './rules/expect-expect';
33
import maxNestedDescribe from './rules/max-nested-describe';
44
import missingPlaywrightAwait from './rules/missing-playwright-await';
55
import noCommentedOutTests from './rules/no-commented-out-tests';
6+
import noConditionalExpect from './rules/no-conditional-expect';
67
import noConditionalInTest from './rules/no-conditional-in-test';
78
import noElementHandle from './rules/no-element-handle';
89
import noEval from './rules/no-eval';
@@ -43,6 +44,7 @@ const index = {
4344
'max-nested-describe': maxNestedDescribe,
4445
'missing-playwright-await': missingPlaywrightAwait,
4546
'no-commented-out-tests': noCommentedOutTests,
47+
'no-conditional-expect': noConditionalExpect,
4648
'no-conditional-in-test': noConditionalInTest,
4749
'no-element-handle': noElementHandle,
4850
'no-eval': noEval,
@@ -84,6 +86,7 @@ const sharedConfig = {
8486
'playwright/expect-expect': 'warn',
8587
'playwright/max-nested-describe': 'warn',
8688
'playwright/missing-playwright-await': 'error',
89+
'playwright/no-conditional-expect': 'warn',
8790
'playwright/no-conditional-in-test': 'warn',
8891
'playwright/no-element-handle': 'warn',
8992
'playwright/no-eval': 'warn',

src/rules/no-conditional-expect.ts

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { Rule, Scope } from 'eslint';
2+
import * as ESTree from 'estree';
3+
import {
4+
getExpectType,
5+
getParent,
6+
isPropertyAccessor,
7+
isTestCall,
8+
} from '../utils/ast';
9+
import { KnownCallExpression } from '../utils/types';
10+
11+
const isCatchCall = (
12+
node: ESTree.CallExpression,
13+
): node is KnownCallExpression =>
14+
node.callee.type === 'MemberExpression' &&
15+
isPropertyAccessor(node.callee, 'catch');
16+
17+
const getTestCallExpressionsFromDeclaredVariables = (
18+
context: Rule.RuleContext,
19+
declaredVariables: readonly Scope.Variable[],
20+
): ESTree.CallExpression[] => {
21+
return declaredVariables.reduce<ESTree.CallExpression[]>(
22+
(acc, { references }) => [
23+
...acc,
24+
...references
25+
.map(({ identifier }) => getParent(identifier))
26+
.filter(
27+
(node): node is ESTree.CallExpression =>
28+
node?.type === 'CallExpression' && isTestCall(context, node),
29+
),
30+
],
31+
[],
32+
);
33+
};
34+
35+
export default {
36+
create(context) {
37+
let conditionalDepth = 0;
38+
let inTestCase = false;
39+
let inPromiseCatch = false;
40+
41+
const increaseConditionalDepth = () => inTestCase && conditionalDepth++;
42+
const decreaseConditionalDepth = () => inTestCase && conditionalDepth--;
43+
44+
return {
45+
CallExpression(node: ESTree.CallExpression) {
46+
if (isTestCall(context, node)) {
47+
inTestCase = true;
48+
}
49+
50+
if (isCatchCall(node)) {
51+
inPromiseCatch = true;
52+
}
53+
54+
const expectType = getExpectType(context, node);
55+
if (inTestCase && expectType && conditionalDepth > 0) {
56+
context.report({
57+
messageId: 'conditionalExpect',
58+
node,
59+
});
60+
}
61+
62+
if (inPromiseCatch && expectType) {
63+
context.report({
64+
messageId: 'conditionalExpect',
65+
node,
66+
});
67+
}
68+
},
69+
'CallExpression:exit'(node) {
70+
if (isTestCall(context, node)) {
71+
inTestCase = false;
72+
}
73+
74+
if (isCatchCall(node)) {
75+
inPromiseCatch = false;
76+
}
77+
},
78+
CatchClause: increaseConditionalDepth,
79+
'CatchClause:exit': decreaseConditionalDepth,
80+
ConditionalExpression: increaseConditionalDepth,
81+
'ConditionalExpression:exit': decreaseConditionalDepth,
82+
FunctionDeclaration(node) {
83+
const declaredVariables = context.sourceCode.getDeclaredVariables(node);
84+
const testCallExpressions = getTestCallExpressionsFromDeclaredVariables(
85+
context,
86+
declaredVariables,
87+
);
88+
89+
if (testCallExpressions.length > 0) {
90+
inTestCase = true;
91+
}
92+
},
93+
IfStatement: increaseConditionalDepth,
94+
'IfStatement:exit': decreaseConditionalDepth,
95+
LogicalExpression: increaseConditionalDepth,
96+
'LogicalExpression:exit': decreaseConditionalDepth,
97+
SwitchStatement: increaseConditionalDepth,
98+
'SwitchStatement:exit': decreaseConditionalDepth,
99+
};
100+
},
101+
meta: {
102+
docs: {
103+
category: 'Best Practices',
104+
description: 'Disallow calling `expect` conditionally',
105+
recommended: true,
106+
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-expect.md',
107+
},
108+
messages: {
109+
conditionalExpect: 'Avoid calling `expect` conditionally`',
110+
},
111+
type: 'problem',
112+
},
113+
} as Rule.RuleModule;

0 commit comments

Comments
 (0)