Skip to content

Commit 9e3e94f

Browse files
rabelfishSimenB
rabelfish
authored andcommitted
feat(rules): no-standalone-expect (#350)
Fixes #342
1 parent 1f92185 commit 9e3e94f

File tree

5 files changed

+289
-1
lines changed

5 files changed

+289
-1
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ installations requiring long-term consistency.
123123
| [no-jest-import][] | Disallow importing `jest` | ![recommended][] | |
124124
| [no-large-snapshots][] | Disallow large snapshots | | |
125125
| [no-mocks-import][] | Disallow manually importing from `__mocks__` | | |
126+
| [no-standalone-expect][] | Prevents `expect` statements outside of a `test` or `it` block | | |
126127
| [no-test-callback][] | Using a callback in asynchronous tests | | ![fixable-green][] |
127128
| [no-test-prefixes][] | Disallow using `f` & `x` prefixes to define focused/skipped tests | ![recommended][] | ![fixable-green][] |
128129
| [no-test-return-statement][] | Disallow explicitly returning from tests | | |
@@ -174,6 +175,7 @@ https://github.com/dangreenisrael/eslint-plugin-jest-formatting
174175
[no-jest-import]: docs/rules/no-jest-import.md
175176
[no-large-snapshots]: docs/rules/no-large-snapshots.md
176177
[no-mocks-import]: docs/rules/no-mocks-import.md
178+
[no-standalone-expect]: docs/rules/no-standalone-expect.md
177179
[no-test-callback]: docs/rules/no-test-callback.md
178180
[no-test-prefixes]: docs/rules/no-test-prefixes.md
179181
[no-test-return-statement]: docs/rules/no-test-return-statement.md

docs/rules/no-standalone-expect.md

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

src/__tests__/rules.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { resolve } from 'path';
33
import { rules } from '../';
44

55
const ruleNames = Object.keys(rules);
6-
const numberOfRules = 36;
6+
const numberOfRules = 37;
77

88
describe('rules', () => {
99
it('should have a corresponding doc for each rule', () => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { TSESLint } from '@typescript-eslint/experimental-utils';
2+
import rule from '../no-standalone-expect';
3+
4+
const ruleTester = new TSESLint.RuleTester({
5+
parserOptions: {
6+
ecmaVersion: 2015,
7+
},
8+
});
9+
10+
ruleTester.run('no-standalone-expect', rule, {
11+
valid: [
12+
'describe("a test", () => { it("an it", () => {expect(1).toBe(1); }); });',
13+
'describe("a test", () => { it("an it", () => { const func = () => { expect(1).toBe(1); }; }); });',
14+
'describe("a test", () => { const func = () => { expect(1).toBe(1); }; });',
15+
'describe("a test", () => { function func() { expect(1).toBe(1); }; });',
16+
'describe("a test", () => { const func = function(){ expect(1).toBe(1); }; });',
17+
'it("an it", () => expect(1).toBe(1))',
18+
'const func = function(){ expect(1).toBe(1); };',
19+
'const func = () => expect(1).toBe(1);',
20+
'expect.hasAssertions()',
21+
'{}',
22+
'it.each([1, true])("trues", value => { expect(value).toBe(true); });',
23+
'it.each([1, true])("trues", value => { expect(value).toBe(true); }); it("an it", () => { expect(1).toBe(1) });',
24+
`
25+
it.each\`
26+
num | value
27+
\${1} | \${true}
28+
\`('trues', ({ value }) => {
29+
expect(value).toBe(true);
30+
});
31+
`,
32+
'it.only("an only", value => { expect(value).toBe(true); });',
33+
'describe.each([1, true])("trues", value => { it("an it", () => expect(value).toBe(true) ); });',
34+
],
35+
invalid: [
36+
{
37+
code: 'describe("a test", () => { expect(1).toBe(1); });',
38+
errors: [{ endColumn: 37, column: 28, messageId: 'unexpectedExpect' }],
39+
},
40+
{
41+
code: 'describe("a test", () => expect(1).toBe(1));',
42+
errors: [{ endColumn: 35, column: 26, messageId: 'unexpectedExpect' }],
43+
},
44+
{
45+
code:
46+
'describe("a test", () => { const func = () => { expect(1).toBe(1); }; expect(1).toBe(1); });',
47+
errors: [{ endColumn: 80, column: 71, messageId: 'unexpectedExpect' }],
48+
},
49+
{
50+
code:
51+
'describe("a test", () => { it(() => { expect(1).toBe(1); }); expect(1).toBe(1); });',
52+
errors: [{ endColumn: 72, column: 63, messageId: 'unexpectedExpect' }],
53+
},
54+
{
55+
code: 'expect(1).toBe(1);',
56+
errors: [{ endColumn: 10, column: 1, messageId: 'unexpectedExpect' }],
57+
},
58+
{
59+
code: 'expect(1).toBe',
60+
errors: [{ endColumn: 10, column: 1, messageId: 'unexpectedExpect' }],
61+
},
62+
{
63+
code: '{expect(1).toBe(1)}',
64+
errors: [{ endColumn: 11, column: 2, messageId: 'unexpectedExpect' }],
65+
},
66+
{
67+
code:
68+
'it.each([1, true])("trues", value => { expect(value).toBe(true); }); expect(1).toBe(1);',
69+
errors: [{ endColumn: 79, column: 70, messageId: 'unexpectedExpect' }],
70+
},
71+
{
72+
code:
73+
'describe.each([1, true])("trues", value => { expect(value).toBe(true); });',
74+
errors: [{ endColumn: 59, column: 46, messageId: 'unexpectedExpect' }],
75+
},
76+
],
77+
});

src/rules/no-standalone-expect.ts

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
AST_NODE_TYPES,
3+
TSESTree,
4+
} from '@typescript-eslint/experimental-utils';
5+
import {
6+
TestCaseName,
7+
createRule,
8+
isDescribe,
9+
isExpectCall,
10+
isFunction,
11+
isTestCase,
12+
} from './tsUtils';
13+
14+
const getBlockType = (
15+
stmt: TSESTree.BlockStatement,
16+
): 'function' | 'describe' | null => {
17+
const func = stmt.parent;
18+
19+
/* istanbul ignore if */
20+
if (!func) {
21+
throw new Error(
22+
`Unexpected BlockStatement. No parent defined. - please file a github issue at https://github.com/jest-community/eslint-plugin-jest`,
23+
);
24+
}
25+
// functionDeclaration: function func() {}
26+
if (func.type === AST_NODE_TYPES.FunctionDeclaration) {
27+
return 'function';
28+
}
29+
if (isFunction(func) && func.parent) {
30+
const expr = func.parent;
31+
// arrowfunction or function expr
32+
if (expr.type === AST_NODE_TYPES.VariableDeclarator) {
33+
return 'function';
34+
}
35+
// if it's not a variable, it will be callExpr, we only care about describe
36+
if (expr.type === AST_NODE_TYPES.CallExpression && isDescribe(expr)) {
37+
return 'describe';
38+
}
39+
}
40+
return null;
41+
};
42+
43+
const isEach = (node: TSESTree.CallExpression): boolean => {
44+
if (
45+
node &&
46+
node.callee &&
47+
node.callee.type === AST_NODE_TYPES.CallExpression &&
48+
node.callee.callee &&
49+
node.callee.callee.type === AST_NODE_TYPES.MemberExpression &&
50+
node.callee.callee.property &&
51+
node.callee.callee.property.type === AST_NODE_TYPES.Identifier &&
52+
node.callee.callee.property.name === 'each' &&
53+
node.callee.callee.object &&
54+
node.callee.callee.object.type === AST_NODE_TYPES.Identifier &&
55+
TestCaseName.hasOwnProperty(node.callee.callee.object.name)
56+
) {
57+
return true;
58+
}
59+
return false;
60+
};
61+
62+
type callStackEntry =
63+
| 'test'
64+
| 'function'
65+
| 'describe'
66+
| 'arrowFunc'
67+
| 'template';
68+
69+
export default createRule({
70+
name: __filename,
71+
meta: {
72+
docs: {
73+
category: 'Best Practices',
74+
description: 'Prevents expects that are outside of an it or test block.',
75+
recommended: false,
76+
},
77+
messages: {
78+
unexpectedExpect: 'Expect must be inside of a test block.',
79+
},
80+
type: 'suggestion',
81+
schema: [],
82+
},
83+
defaultOptions: [],
84+
create(context) {
85+
const callStack: callStackEntry[] = [];
86+
87+
return {
88+
CallExpression(node) {
89+
if (isExpectCall(node)) {
90+
const parent = callStack[callStack.length - 1];
91+
if (!parent || parent === 'describe') {
92+
context.report({ node, messageId: 'unexpectedExpect' });
93+
}
94+
return;
95+
}
96+
if (isTestCase(node)) {
97+
callStack.push('test');
98+
}
99+
if (node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression) {
100+
callStack.push('template');
101+
}
102+
},
103+
'CallExpression:exit'(node: TSESTree.CallExpression) {
104+
const top = callStack[callStack.length - 1];
105+
if (
106+
(((isTestCase(node) &&
107+
node.callee.type !== AST_NODE_TYPES.MemberExpression) ||
108+
isEach(node)) &&
109+
top === 'test') ||
110+
(node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression &&
111+
top === 'template')
112+
) {
113+
callStack.pop();
114+
}
115+
},
116+
BlockStatement(stmt) {
117+
const blockType = getBlockType(stmt);
118+
if (blockType) {
119+
callStack.push(blockType);
120+
}
121+
},
122+
'BlockStatement:exit'(stmt: TSESTree.BlockStatement) {
123+
const blockType = getBlockType(stmt);
124+
if (blockType && blockType === callStack[callStack.length - 1]) {
125+
callStack.pop();
126+
}
127+
},
128+
ArrowFunctionExpression(node) {
129+
if (node.parent && node.parent.type !== AST_NODE_TYPES.CallExpression) {
130+
callStack.push('arrowFunc');
131+
}
132+
},
133+
'ArrowFunctionExpression:exit'() {
134+
if (callStack[callStack.length - 1] === 'arrowFunc') {
135+
callStack.pop();
136+
}
137+
},
138+
};
139+
},
140+
});

0 commit comments

Comments
 (0)