diff --git a/README.md b/README.md index e2a312a..776a362 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,11 @@ To use the all configuration, extend it in your `.eslintrc` file: | [prefer-each](docs/rules/prefer-each.md) | Prefer `each` rather than manual loops | 🌐 | | | | [prefer-equality-matcher](docs/rules/prefer-equality-matcher.md) | Suggest using the built-in quality matchers | 🌐 | | 💡 | | [prefer-expect-resolves](docs/rules/prefer-expect-resolves.md) | Suggest using `expect().resolves` over `expect(await ...)` syntax | 🌐 | 🔧 | | +<<<<<<< HEAD | [prefer-hooks-on-top](docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | 🌐 | | | +======= +| [prefer-hooks-in-order](docs/rules/prefer-hooks-in-order.md) | Prefer having hooks in consistent order | 🌐 | | | +>>>>>>> origin | [prefer-lowercase-title](docs/rules/prefer-lowercase-title.md) | Enforce lowercase titles | 🌐 | 🔧 | | | [prefer-strict-equal](docs/rules/prefer-strict-equal.md) | Prefer strict equal over equal | 🌐 | | 💡 | | [prefer-to-be](docs/rules/prefer-to-be.md) | Suggest using toBe() | ✅ | 🔧 | | diff --git a/docs/rules/prefer-hooks-in-order.md b/docs/rules/prefer-hooks-in-order.md new file mode 100644 index 0000000..9479a31 --- /dev/null +++ b/docs/rules/prefer-hooks-in-order.md @@ -0,0 +1,30 @@ +# Prefer having hooks in consistent order (`vitest/prefer-hooks-in-order`) + +⚠️ This rule _warns_ in the 🌐 `all` config. + + + +```js + // consistent order of hooks + ['beforeAll', 'beforeEach', 'afterEach', 'afterAll'] +``` + +```js + // bad + afterAll(() => { + removeMyDatabase(); + }); + beforeAll(() => { + createMyDatabase(); + }); +``` + +```js + // good + beforeAll(() => { + createMyDatabase(); + }); + afterAll(() => { + removeMyDatabase(); + }); +``` \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5dddb3f..5e13982 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ import preferStrictEqual, { RULE_NAME as preferStrictEqualName } from './rules/p import preferExpectResolves, { RULE_NAME as preferExpectResolvesName } from './rules/prefer-expect-resolves' import preferEach, { RULE_NAME as preferEachName } from './rules/prefer-each' import preferHooksOnTop, { RULE_NAME as preferHooksOnTopName } from './rules/prefer-hooks-on-top' +import preferHooksInOrder, { RULE_NAME as preferHooksInOrderName } from './rules/prefer-hooks-in-order' const createConfig = (rules: Record) => ({ plugins: ['vitest'], @@ -79,7 +80,8 @@ const allRules = { [preferStrictEqualName]: 'warn', [preferExpectResolvesName]: 'warn', [preferEachName]: 'warn', - [preferHooksOnTopName]: 'warn' + [preferHooksOnTopName]: 'warn', + [preferHooksInOrderName]: 'warn' } const recommended = { @@ -130,7 +132,8 @@ export default { [preferStrictEqualName]: preferStrictEqual, [preferExpectResolvesName]: preferExpectResolves, [preferEachName]: preferEach, - [preferHooksOnTopName]: preferHooksOnTop + [preferHooksOnTopName]: preferHooksOnTop, + [preferHooksInOrderName]: preferHooksInOrder }, configs: { all: createConfig(allRules), diff --git a/src/rules/prefer-hooks-in-order.test.ts b/src/rules/prefer-hooks-in-order.test.ts new file mode 100644 index 0000000..a5ecda4 --- /dev/null +++ b/src/rules/prefer-hooks-in-order.test.ts @@ -0,0 +1,681 @@ +import { describe, it } from 'vitest' +import ruleTester from '../utils/tester' +import rule, { RULE_NAME } from './prefer-hooks-in-order' + +describe(RULE_NAME, () => { + it(RULE_NAME, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + 'beforeAll(() => {})', + 'beforeEach(() => {})', + 'afterEach(() => {})', + 'afterAll(() => {})', + 'describe(() => {})', + `beforeAll(() => {}); + beforeEach(() => {}); + afterEach(() => {}); + afterAll(() => {});`, + `describe('foo', () => { + someSetupFn(); + beforeEach(() => {}); + afterEach(() => {}); + + test('bar', () => { + someFn(); + }); + });`, + ` + beforeAll(() => {}); + afterAll(() => {}); + `, + ` + beforeEach(() => {}); + afterEach(() => {}); + `, + ` + beforeAll(() => {}); + afterEach(() => {}); + `, + ` + beforeAll(() => {}); + beforeEach(() => {}); + `, + ` + afterEach(() => {}); + afterAll(() => {}); + `, + ` + beforeAll(() => {}); + beforeAll(() => {}); + `, + ` + describe('my test', () => { + afterEach(() => {}); + afterAll(() => {}); + }); + `, + ` + describe('my test', () => { + afterEach(() => {}); + afterAll(() => {}); + + doSomething(); + + beforeAll(() => {}); + beforeEach(() => {}); + }); + `, + ` + describe('my test', () => { + afterEach(() => {}); + afterAll(() => {}); + + it('is a test', () => {}); + + beforeAll(() => {}); + beforeEach(() => {}); + }); + `, + ` + describe('my test', () => { + afterAll(() => {}); + + describe('when something is true', () => { + beforeAll(() => {}); + beforeEach(() => {}); + }); + }); + `, + ` + describe('my test', () => { + afterAll(() => {}); + + describe('when something is true', () => { + beforeAll(() => {}); + beforeEach(() => {}); + + it('does something', () => {}); + + beforeAll(() => {}); + beforeEach(() => {}); + }); + + beforeAll(() => {}); + beforeEach(() => {}); + }); + + describe('my test', () => { + beforeAll(() => {}); + beforeEach(() => {}); + afterAll(() => {}); + + describe('when something is true', () => { + it('does something', () => {}); + + beforeAll(() => {}); + beforeEach(() => {}); + }); + + beforeAll(() => {}); + beforeEach(() => {}); + }); + `, + ` + const withDatabase = () => { + beforeAll(() => { + createMyDatabase(); + }); + afterAll(() => { + removeMyDatabase(); + }); + }; + + describe('my test', () => { + withDatabase(); + + afterAll(() => {}); + + describe('when something is true', () => { + beforeAll(() => {}); + beforeEach(() => {}); + + it('does something', () => {}); + + beforeAll(() => {}); + beforeEach(() => {}); + }); + + beforeAll(() => {}); + beforeEach(() => {}); + }); + + describe('my test', () => { + beforeAll(() => {}); + beforeEach(() => {}); + afterAll(() => {}); + + withDatabase(); + + describe('when something is true', () => { + it('does something', () => {}); + + beforeAll(() => {}); + beforeEach(() => {}); + }); + + beforeAll(() => {}); + beforeEach(() => {}); + }); + `, + ` + describe('foo', () => { + beforeAll(() => { + createMyDatabase(); + }); + + beforeEach(() => { + seedMyDatabase(); + }); + + it('accepts this input', () => { + // ... + }); + + it('returns that value', () => { + // ... + }); + + describe('when the database has specific values', () => { + const specificValue = '...'; + + beforeEach(() => { + seedMyDatabase(specificValue); + }); + + it('accepts that input', () => { + // ... + }); + + it('throws an error', () => { + // ... + }); + + beforeEach(() => { + mockLogger(); + }); + + afterEach(() => { + clearLogger(); + }); + + it('logs a message', () => { + // ... + }); + }); + + afterAll(() => { + removeMyDatabase(); + }); + }); + `, + ` + describe('A file with a lot of test', () => { + beforeAll(() => { + setupTheDatabase(); + createMocks(); + }); + + beforeAll(() => { + doEvenMore(); + }); + + beforeEach(() => { + cleanTheDatabase(); + resetSomeThings(); + }); + + afterEach(() => { + cleanTheDatabase(); + resetSomeThings(); + }); + + afterAll(() => { + closeTheDatabase(); + stop(); + }); + + it('does something', () => { + const thing = getThing(); + expect(thing).toBe('something'); + }); + + it('throws', () => { + // Do something that throws + }); + + describe('Also have tests in here', () => { + afterAll(() => {}); + it('tests something', () => {}); + it('tests something else', () => {}); + beforeAll(()=>{}); + }); + }); + ` + ], + invalid: [ + { + code: ` + const withDatabase = () => { + afterAll(() => { + removeMyDatabase(); + }); + beforeAll(() => { + createMyDatabase(); + }); + }; + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'afterAll' }, + column: 7, + line: 6 + } + ] + }, + { + code: ` + afterAll(() => { + removeMyDatabase(); + }); + beforeAll(() => { + createMyDatabase(); + }); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'afterAll' }, + column: 8, + line: 5 + } + ] + }, + { + code: ` + afterAll(() => {}); + beforeAll(() => {}); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'afterAll' }, + column: 8, + line: 3 + } + ] + }, + { + code: ` + afterEach(() => {}); + beforeEach(() => {}); + `, + errors: [ + { + // 'beforeEach' hooks should be before any 'afterEach' hooks + messageId: 'reorderHooks', + data: { currentHook: 'beforeEach', previousHook: 'afterEach' }, + column: 8, + line: 3 + } + ] + }, + { + code: ` + afterEach(() => {}); + beforeAll(() => {}); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'afterEach' }, + column: 8, + line: 3 + } + ] + }, + { + code: ` + beforeEach(() => {}); + beforeAll(() => {}); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'beforeEach' }, + column: 8, + line: 3 + } + ] + }, + { + code: ` + afterAll(() => {}); + afterEach(() => {}); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'afterEach', previousHook: 'afterAll' }, + column: 8, + line: 3 + } + ] + }, + { + code: ` + afterAll(() => {}); + // The afterEach should do this + // This comment does not matter for the order + afterEach(() => {}); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'afterEach', previousHook: 'afterAll' }, + column: 8, + line: 5 + } + ] + }, + { + code: ` + afterAll(() => {}); + afterAll(() => {}); + afterEach(() => {}); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'afterEach', previousHook: 'afterAll' }, + column: 8, + line: 4 + } + ] + }, + { + code: ` + describe('my test', () => { + afterAll(() => {}); + afterEach(() => {}); + }); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'afterEach', previousHook: 'afterAll' }, + column: 7, + line: 4 + } + ] + }, + { + code: ` + describe('my test', () => { + afterAll(() => {}); + afterEach(() => {}); + + doSomething(); + + beforeEach(() => {}); + beforeAll(() => {}); + }); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'afterEach', previousHook: 'afterAll' }, + column: 7, + line: 4 + }, + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'beforeEach' }, + column: 7, + line: 9 + } + ] + }, + { + code: ` + describe('my test', () => { + afterAll(() => {}); + afterEach(() => {}); + + it('is a test', () => {}); + + beforeEach(() => {}); + beforeAll(() => {}); + }); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'afterEach', previousHook: 'afterAll' }, + column: 7, + line: 4 + }, + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'beforeEach' }, + column: 7, + line: 9 + } + ] + }, + { + code: ` + describe('my test', () => { + afterAll(() => {}); + + describe('when something is true', () => { + beforeEach(() => {}); + beforeAll(() => {}); + }); + }); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'beforeEach' }, + column: 9, + line: 7 + } + ] + }, + { + code: ` + describe('my test', () => { + beforeAll(() => {}); + afterAll(() => {}); + beforeAll(() => {}); + + describe('when something is true', () => { + beforeAll(() => {}); + afterEach(() => {}); + beforeEach(() => {}); + afterEach(() => {}); + }); + }); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'afterAll' }, + column: 7, + line: 5 + }, + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeEach', previousHook: 'afterEach' }, + column: 9, + line: 10 + } + ] + }, + { + code: ` + describe('my test', () => { + beforeAll(() => {}); + beforeAll(() => {}); + afterAll(() => {}); + + it('foo nested', () => { + // this is a test + }); + + describe('when something is true', () => { + beforeAll(() => {}); + afterEach(() => {}); + + it('foo nested', () => { + // this is a test + }); + + describe('deeply nested', () => { + afterAll(() => {}); + afterAll(() => {}); + // This comment does nothing + afterEach(() => {}); + + it('foo nested', () => { + // this is a test + }); + }) + beforeEach(() => {}); + afterEach(() => {}); + }); + }); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'afterEach', previousHook: 'afterAll' }, + column: 8, + line: 23 + } + ] + }, + { + code: ` + describe('my test', () => { + const setupDatabase = () => { + beforeEach(() => { + initDatabase(); + fillWithData(); + }); + beforeAll(() => { + setupMocks(); + }); + }; + + it('foo', () => { + // this is a test + }); + + describe('my nested test', () => { + afterAll(() => {}); + afterEach(() => {}); + + it('foo nested', () => { + // this is a test + }); + }); + }); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'beforeEach' }, + column: 9, + line: 8 + }, + { + messageId: 'reorderHooks', + data: { currentHook: 'afterEach', previousHook: 'afterAll' }, + column: 9, + line: 19 + } + ] + }, + { + code: ` + describe('foo', () => { + beforeEach(() => { + seedMyDatabase(); + }); + + beforeAll(() => { + createMyDatabase(); + }); + + it('accepts this input', () => { + // ... + }); + + it('returns that value', () => { + // ... + }); + + describe('when the database has specific values', () => { + const specificValue = '...'; + + beforeEach(() => { + seedMyDatabase(specificValue); + }); + + it('accepts that input', () => { + // ... + }); + + it('throws an error', () => { + // ... + }); + + afterEach(() => { + clearLogger(); + }); + + beforeEach(() => { + mockLogger(); + }); + + it('logs a message', () => { + // ... + }); + }); + + afterAll(() => { + removeMyDatabase(); + }); + }); + `, + errors: [ + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeAll', previousHook: 'beforeEach' }, + column: 7, + line: 7 + }, + { + messageId: 'reorderHooks', + data: { currentHook: 'beforeEach', previousHook: 'afterEach' }, + column: 9, + line: 38 + } + ] + } + ] + }) + }) +}) diff --git a/src/rules/prefer-hooks-in-order.ts b/src/rules/prefer-hooks-in-order.ts new file mode 100644 index 0000000..c505659 --- /dev/null +++ b/src/rules/prefer-hooks-in-order.ts @@ -0,0 +1,71 @@ +import { createEslintRule } from '../utils/index' +import { isTypeOfVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' + +export const RULE_NAME = 'prefer-hooks-in-order' +type MESSAGE_IDS = 'reorderHooks'; +type Options = [] + +const HooksOrder = ['beforeAll', 'beforeEach', 'afterEach', 'afterAll'] + +export default createEslintRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'Prefer having hooks in consistent order', + recommended: 'warn' + }, + messages: { + reorderHooks: '`{{ currentHook }}` hooks should be before any `{{ previousHook }}` hooks' + }, + schema: [] + }, + defaultOptions: [], + create(context) { + let previousHookIndex = -1 + let inHook = false + + return { + CallExpression(node) { + if (inHook) return + + const vitestFnCall = parseVitestFnCall(node, context) + + if (vitestFnCall?.type !== 'hook') { + previousHookIndex = -1 + return + } + + inHook = true + const currentHook = vitestFnCall.name + const currentHookIndex = HooksOrder.indexOf(currentHook) + + if (currentHookIndex < previousHookIndex) { + context.report({ + messageId: 'reorderHooks', + data: { + previousHook: HooksOrder[previousHookIndex], + currentHook + }, + node + }) + inHook = false + return + } + + previousHookIndex = currentHookIndex + }, + 'CallExpression:exit'(node) { + if (isTypeOfVitestFnCall(node, context, ['hook'])) { + inHook = false + return + } + + if (inHook) + return + + previousHookIndex = -1 + } + } + } +})