Skip to content

Commit 348c4e8

Browse files
MrHensindresorhus
andcommitted
Add hooks-order rule (#265)
Co-authored-by: Sindre Sorhus <[email protected]>
1 parent b829dbc commit 348c4e8

File tree

6 files changed

+964
-0
lines changed

6 files changed

+964
-0
lines changed

docs/rules/hooks-order.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Enforce test hook ordering
2+
3+
Hooks should be placed before any tests and in the proper semantic order:
4+
5+
- `test.before(…);`
6+
- `test.after(…);`
7+
- `test.after.always(…);`
8+
- `test.beforeEach(…);`
9+
- `test.afterEach(…);`
10+
- `test.afterEach.always(…);`
11+
- `test(…);`
12+
13+
This rule is fixable as long as no other code is between the hooks that need to be reordered.
14+
15+
16+
## Fail
17+
18+
```js
19+
import test from 'ava';
20+
21+
test.after(t => {
22+
doFoo();
23+
});
24+
25+
test.before(t => {
26+
doFoo();
27+
});
28+
29+
test('foo', t => {
30+
t.true(true);
31+
});
32+
```
33+
34+
```js
35+
import test from 'ava';
36+
37+
test('foo', t => {
38+
t.true(true);
39+
});
40+
41+
test.before(t => {
42+
doFoo();
43+
});
44+
```
45+
46+
47+
## Pass
48+
49+
```js
50+
import test from 'ava';
51+
52+
test.before(t => {
53+
doFoo();
54+
});
55+
56+
test.after(t => {
57+
doFoo();
58+
});
59+
60+
test.after.always(t => {
61+
doFoo();
62+
});
63+
64+
test.beforeEach(t => {
65+
doFoo();
66+
});
67+
68+
test.afterEach(t => {
69+
doFoo();
70+
});
71+
72+
test.afterEach.always(t => {
73+
doFoo();
74+
});
75+
76+
test('foo', t => {
77+
t.true(true);
78+
});
79+
```

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ module.exports = {
1818
],
1919
rules: {
2020
'ava/assertion-arguments': 'error',
21+
'ava/hooks-order': 'error',
2122
'ava/max-asserts': [
2223
'off',
2324
5

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"eslint-plugin-eslint-plugin": "2.1.0",
4747
"js-combinatorics": "^0.5.4",
4848
"nyc": "^14.1.1",
49+
"outdent": "^0.7.0",
4950
"pify": "^4.0.1",
5051
"xo": "^0.24.0"
5152
},

readme.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Configure it in `package.json`.
3636
],
3737
"rules": {
3838
"ava/assertion-arguments": "error",
39+
"ava/hooks-order": "error",
3940
"ava/max-asserts": [
4041
"off",
4142
5
@@ -77,6 +78,7 @@ Configure it in `package.json`.
7778
The rules will only activate in test files.
7879

7980
- [assertion-arguments](docs/rules/assertion-arguments.md) - Enforce passing correct arguments to assertions.
81+
- [hooks-order](docs/rules/hooks-order.md) - Enforce test hook ordering. *(fixable)*
8082
- [max-asserts](docs/rules/max-asserts.md) - Limit the number of assertions in a test.
8183
- [no-async-fn-without-await](docs/rules/no-async-fn-without-await.md) - Ensure that async tests use `await`.
8284
- [no-cb-test](docs/rules/no-cb-test.md) - Ensure no `test.cb()` is used.

rules/hooks-order.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
'use strict';
2+
const {visitIf} = require('enhance-visitors');
3+
const createAvaRule = require('../create-ava-rule');
4+
const util = require('../util');
5+
6+
const MESSAGE_ID = 'hooks-order';
7+
8+
const buildOrders = names => {
9+
const orders = {};
10+
for (const nameLater of names) {
11+
for (const nameEarlier in orders) {
12+
if (orders[nameEarlier]) {
13+
orders[nameEarlier].push(nameLater);
14+
}
15+
}
16+
17+
orders[nameLater] = [];
18+
}
19+
20+
return orders;
21+
};
22+
23+
const buildMessage = (name, orders, visited) => {
24+
const checks = orders[name] || [];
25+
26+
for (const check of checks) {
27+
const nodeEarlier = visited[check];
28+
if (nodeEarlier) {
29+
return {
30+
messageId: MESSAGE_ID,
31+
data: {
32+
current: name,
33+
invalid: check
34+
},
35+
node: nodeEarlier
36+
};
37+
}
38+
}
39+
40+
return null;
41+
};
42+
43+
const create = context => {
44+
const ava = createAvaRule();
45+
46+
const orders = buildOrders([
47+
'before',
48+
'after',
49+
'after.always',
50+
'beforeEach',
51+
'afterEach',
52+
'afterEach.always',
53+
'test'
54+
]);
55+
56+
const visited = {};
57+
58+
const checks = [
59+
{
60+
selector: 'CallExpression[callee.object.name="test"][callee.property.name="before"]',
61+
name: 'before'
62+
},
63+
{
64+
selector: 'CallExpression[callee.object.name="test"][callee.property.name="after"]',
65+
name: 'after'
66+
},
67+
{
68+
selector: 'CallExpression[callee.object.object.name="test"][callee.object.property.name="after"][callee.property.name="always"]',
69+
name: 'after.always'
70+
},
71+
{
72+
selector: 'CallExpression[callee.object.name="test"][callee.property.name="beforeEach"]',
73+
name: 'beforeEach'
74+
},
75+
{
76+
selector: 'CallExpression[callee.object.name="test"][callee.property.name="afterEach"]',
77+
name: 'afterEach'
78+
},
79+
{
80+
selector: 'CallExpression[callee.object.object.name="test"][callee.object.property.name="afterEach"][callee.property.name="always"]',
81+
name: 'afterEach.always'
82+
},
83+
{
84+
selector: 'CallExpression[callee.name="test"]',
85+
name: 'test'
86+
}
87+
];
88+
89+
const sourceCode = context.getSourceCode();
90+
91+
const selectors = checks.reduce((result, check) => {
92+
result[check.selector] = visitIf([
93+
ava.isInTestFile,
94+
ava.isTestNode
95+
])(node => {
96+
visited[check.name] = node;
97+
98+
const message = buildMessage(check.name, orders, visited);
99+
if (message) {
100+
const nodeEarlier = message.node;
101+
102+
context.report({
103+
node,
104+
messageId: message.messageId,
105+
data: message.data,
106+
fix: fixer => {
107+
const tokensBetween = sourceCode.getTokensBetween(nodeEarlier.parent, node.parent);
108+
109+
if (tokensBetween && tokensBetween.length > 0) {
110+
return;
111+
}
112+
113+
const source = sourceCode.getText();
114+
let [insertStart, insertEnd] = nodeEarlier.parent.range;
115+
116+
// Grab the node and all comments and whitespace before the node
117+
const start = nodeEarlier.parent.range[1];
118+
const end = node.parent.range[1];
119+
120+
let text = sourceCode.getText().substring(start, end);
121+
122+
// Preserve newline previously between hooks
123+
if (source.length >= (start + 1) && source[start + 1] === '\n') {
124+
text = text.substring(1) + '\n';
125+
}
126+
127+
// Preserve newline that was previously before hooks
128+
if ((insertStart - 1) > 0 && source[insertStart - 1] === '\n') {
129+
insertStart -= 1;
130+
}
131+
132+
return [
133+
fixer.insertTextBeforeRange([insertStart, insertEnd], text),
134+
fixer.removeRange([start, end])
135+
];
136+
}
137+
});
138+
}
139+
});
140+
return result;
141+
}, {});
142+
143+
return ava.merge(selectors);
144+
};
145+
146+
module.exports = {
147+
create,
148+
meta: {
149+
docs: {
150+
url: util.getDocsUrl(__filename)
151+
},
152+
messages: {
153+
[MESSAGE_ID]: '`{{current}}` hook must come before `{{invalid}}`'
154+
},
155+
type: 'suggestion',
156+
fixable: 'code'
157+
}
158+
};

0 commit comments

Comments
 (0)