Skip to content

Commit db570c8

Browse files
feat: add rule consistent-each-for (#826)
* feat: add rule consistent-each-for * refactor: remove unnecessary type assertions in consistent-each-for tests * update * remove unnecessary comments --------- Co-authored-by: Verite Mugabo <mugaboverite@gmail.com>
1 parent 962defb commit db570c8

File tree

6 files changed

+476
-0
lines changed

6 files changed

+476
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export default defineConfig({
187187

188188
| Name                                                | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | 💭 ||
189189
| :----------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------- | :-- | :-- | :-- | :-- | :-- | :-- | :-- |
190+
| [consistent-each-for](docs/rules/consistent-each-for.md) | enforce using `.each` or `.for` consistently | | 🌐 | | | | | |
190191
| [consistent-test-filename](docs/rules/consistent-test-filename.md) | require test file pattern | | 🌐 | | | | | |
191192
| [consistent-test-it](docs/rules/consistent-test-it.md) | enforce using test or it but not both | | 🌐 | | 🔧 | | | |
192193
| [consistent-vitest-vi](docs/rules/consistent-vitest-vi.md) | enforce using vitest or vi but not both | | 🌐 | | 🔧 | | | |

docs/rules/consistent-each-for.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Enforce using `.each` or `.for` consistently (`vitest/consistent-each-for`)
2+
3+
⚠️ This rule _warns_ in the 🌐 `all` config.
4+
5+
<!-- end auto-generated rule header -->
6+
7+
## Rule Details
8+
9+
Vitest provides two ways to run parameterized tests: `.each` and `.for`. This rule enforces consistency in the usage of these methods.
10+
11+
**Key Differences:**
12+
13+
- **`.each`**: Spreads array arguments to individual parameters
14+
- **`.for`**: Keeps arrays intact, provides better TestContext support
15+
16+
This rule allows you to configure which method to prefer for different test function types (`test`, `it`, `describe`, `suite`).
17+
18+
Examples of **incorrect** code when configured to prefer `.for`:
19+
20+
```js
21+
// { test: 'for' }
22+
test.each([[1, 1, 2]])('test', (a, b, expected) => {
23+
expect(a + b).toBe(expected)
24+
})
25+
```
26+
27+
```js
28+
// { describe: 'for' }
29+
describe.each([[1], [2]])('suite %s', (n) => {
30+
test('test', () => {})
31+
})
32+
```
33+
34+
Examples of **correct** code when configured to prefer `.for`:
35+
36+
```js
37+
// { test: 'for' }
38+
test.for([[1, 1, 2]])('test', ([a, b, expected]) => {
39+
expect(a + b).toBe(expected)
40+
})
41+
```
42+
43+
```js
44+
// { describe: 'for' }
45+
describe.for([[1], [2]])('suite %s', ([n]) => {
46+
test('test', () => {})
47+
})
48+
```
49+
50+
Examples of **incorrect** code when configured to prefer `.each`:
51+
52+
```js
53+
// { test: 'each' }
54+
test.for([[1, 1, 2]])('test', ([a, b, expected]) => {
55+
expect(a + b).toBe(expected)
56+
})
57+
```
58+
59+
Examples of **correct** code when configured to prefer `.each`:
60+
61+
```js
62+
// { test: 'each' }
63+
test.each([[1, 1, 2]])('test', (a, b, expected) => {
64+
expect(a + b).toBe(expected)
65+
})
66+
```
67+
68+
```js
69+
// { test: 'each' }
70+
test.skip.each([[1, 2]])('test', (a, b) => {
71+
expect(a).toBeLessThan(b)
72+
})
73+
```
74+
75+
## Options
76+
77+
<!-- begin auto-generated rule options list -->
78+
79+
| Name | Type | Choices |
80+
| :--------- | :----- | :------------ |
81+
| `describe` | String | `each`, `for` |
82+
| `it` | String | `each`, `for` |
83+
| `suite` | String | `each`, `for` |
84+
| `test` | String | `each`, `for` |
85+
86+
<!-- end auto-generated rule options list -->
87+
88+
## Configuration
89+
90+
Typical configuration to enforce `.for` for tests and `.each` for describe blocks:
91+
92+
```js
93+
// eslint.config.js
94+
export default [
95+
{
96+
rules: {
97+
'vitest/consistent-each-for': [
98+
'warn',
99+
{
100+
test: 'for',
101+
it: 'for',
102+
describe: 'each',
103+
suite: 'each',
104+
},
105+
],
106+
},
107+
},
108+
]
109+
```
110+
111+
You can configure each function type independently. If a function type is not configured, the rule won't enforce any preference for it.

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const createConfigLegacy = (rules: Record<string, string>) => ({
2525
const allRules = {
2626
'consistent-test-filename': 'warn',
2727
'consistent-test-it': 'warn',
28+
'consistent-each-for': 'warn',
2829
'consistent-vitest-vi': 'warn',
2930
'expect-expect': 'warn',
3031
'hoisted-apis-on-top': 'warn',

src/rules/consistent-each-for.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { TSESTree } from '@typescript-eslint/utils'
2+
import { createEslintRule, getAccessorValue } from '../utils'
3+
import { parseVitestFnCall } from '../utils/parse-vitest-fn-call'
4+
5+
export const RULE_NAME = 'consistent-each-for'
6+
export type MessageIds = 'consistentMethod'
7+
8+
type EachOrFor = 'each' | 'for'
9+
10+
const BASE_FN_NAMES = ['test', 'it', 'describe', 'suite']
11+
12+
type Options = {
13+
test?: EachOrFor
14+
it?: EachOrFor
15+
describe?: EachOrFor
16+
suite?: EachOrFor
17+
}
18+
19+
export default createEslintRule<[Partial<Options>], MessageIds>({
20+
name: RULE_NAME,
21+
meta: {
22+
type: 'suggestion',
23+
docs: {
24+
description: 'enforce using `.each` or `.for` consistently',
25+
recommended: false,
26+
},
27+
messages: {
28+
consistentMethod:
29+
'Prefer using `{{ functionName }}.{{ preferred }}` over `{{ functionName }}.{{ actual }}`',
30+
},
31+
schema: [
32+
{
33+
type: 'object',
34+
properties: {
35+
test: {
36+
type: 'string',
37+
enum: ['each', 'for'],
38+
},
39+
it: {
40+
type: 'string',
41+
enum: ['each', 'for'],
42+
},
43+
describe: {
44+
type: 'string',
45+
enum: ['each', 'for'],
46+
},
47+
suite: {
48+
type: 'string',
49+
enum: ['each', 'for'],
50+
},
51+
},
52+
additionalProperties: false,
53+
},
54+
],
55+
defaultOptions: [{}],
56+
},
57+
defaultOptions: [{}],
58+
create(context, [options]) {
59+
return {
60+
CallExpression(node: TSESTree.CallExpression) {
61+
const vitestFnCall = parseVitestFnCall(node, context)
62+
63+
if (!vitestFnCall) return
64+
65+
const baseFunctionName = vitestFnCall.name.replace(/^[fx]/, '')
66+
67+
if (!BASE_FN_NAMES.includes(baseFunctionName)) return
68+
69+
const eachMember = vitestFnCall.members.find(
70+
(member) => getAccessorValue(member) === 'each',
71+
)
72+
73+
const forMember = vitestFnCall.members.find(
74+
(member) => getAccessorValue(member) === 'for',
75+
)
76+
77+
if (!eachMember && !forMember) return
78+
79+
const preference = options[baseFunctionName as keyof Options]
80+
81+
if (!preference) return
82+
83+
const actual: EachOrFor = eachMember ? 'each' : 'for'
84+
85+
if (actual !== preference) {
86+
context.report({
87+
node: (eachMember || forMember)!,
88+
messageId: 'consistentMethod',
89+
data: {
90+
functionName: vitestFnCall.name,
91+
preferred: preference,
92+
actual,
93+
},
94+
})
95+
}
96+
},
97+
}
98+
},
99+
})

src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Linter } from 'eslint'
2+
import consistentEachFor from './consistent-each-for'
23
import consistentTestFilename from './consistent-test-filename'
34
import consistentTestIt from './consistent-test-it'
45
import consistentVitestVi from './consistent-vitest-vi'
@@ -78,6 +79,7 @@ import validTitle from './valid-title'
7879
import warnTodo from './warn-todo'
7980

8081
export const rules = {
82+
'consistent-each-for': consistentEachFor,
8183
'consistent-test-filename': consistentTestFilename,
8284
'consistent-test-it': consistentTestIt,
8385
'consistent-vitest-vi': consistentVitestVi,

0 commit comments

Comments
 (0)