├── src ├── utils │ ├── followAssertionChains.ts │ ├── tester.ts │ ├── types.ts │ └── msc.ts └── rules │ ├── prefer-hooks-on-top.ts │ ├── no-commented-out-tests.ts │ ├── require-to-throw-message.ts │ ├── no-conditional-in-test.ts │ ├── no-hooks.ts │ ├── no-interpolation-in-snapshots.ts │ ├── prefer-strict-equal.ts │ ├── no-mocks-import.ts │ ├── prefer-expect-resolves.ts │ ├── prefer-called-with.ts │ ├── no-duplicate-hooks.ts │ ├── no-restricted-vi-methods.ts │ ├── no-conditional-tests.ts │ ├── no-test-prefixes.ts │ ├── prefer-to-be-falsy.ts │ ├── prefer-to-be-truthy.ts │ ├── prefer-hooks-in-order.ts │ ├── no-alias-methods.ts │ ├── prefer-to-have-length.ts │ ├── max-nested-describe.ts │ ├── no-test-return-statement.ts │ ├── no-focused-tests.ts │ ├── consistent-test-filename.ts │ ├── max-expect.ts │ ├── max-expects.ts │ ├── no-restricted-matchers.ts │ ├── prefer-each.ts │ ├── unbound-method.ts │ ├── no-identical-title.ts │ ├── no-disabled-tests.ts │ ├── require-top-level-describe.ts │ ├── prefer-todo.ts │ ├── no-conditional-expect.ts │ ├── prefer-to-contain.ts │ ├── require-hook.ts │ ├── prefer-to-be-object.ts │ ├── prefer-mock-promise-shorthand.ts │ ├── expect-expect.ts │ ├── valid-describe-callback.ts │ ├── prefer-spy-on.ts │ └── prefer-snapshot-hint.ts ├── tests ├── fixtures │ ├── file.ts │ ├── class.ts │ └── tsconfig.json ├── ruleTester.ts ├── consistent-test-filename.test.ts ├── no-identical-title.test.ts ├── unbound-method.test.ts ├── prefer-expect-resolves.test.ts ├── no-focused-tests.test.ts ├── no-mocks-import.test.ts ├── prefer-strict-equal.test.ts ├── no-test-return-statement.test.ts ├── prefer-called-with.test.ts ├── prefer-lowercase-title.test.ts ├── no-alias-methods.test.ts ├── max-nested-describe.test.ts ├── no-hooks.test.ts ├── no-restricted-vi-methods.test.ts ├── no-commented-out-tests.test.ts ├── prefer-to-be-falsy.test.ts ├── prefer-to-be-truthy.test.ts ├── prefer-todo.test.ts ├── prefer-to-be-object.test.ts ├── no-interpolation-in-snapshots.test.ts ├── prefer-to-have-length.test.ts ├── prefer-comparison-matcher.test.ts ├── no-disabled-tests.test.ts ├── expect-expect.test.ts ├── max-expect.test.ts ├── max-expects.test.ts ├── no-conditional-expect.test.ts ├── no-standalone-expect.test.ts └── no-done-callback.test.ts ├── .npmignore ├── .eslintignore ├── .gitignore ├── fixtures ├── .eslintrc ├── index.test.ts └── package.json ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ ├── ci.yml │ └── smoke-test.yml ├── .eslintrc.json ├── unbuild.config.ts ├── docs └── rules │ ├── prefer-each.md │ ├── prefer-to-be-object.md │ ├── no-mocks-import.md │ ├── prefer-to-have-length.md │ ├── prefer-strict-equal.md │ ├── no-disabled-tests.md │ ├── prefer-to-be-truthy.md │ ├── prefer-hooks-on-top.md │ ├── no-test-return-statement.md │ ├── no-commented-out-tests.md │ ├── no-conditional-expect.md │ ├── no-conditional-in-test.md │ ├── prefer-expect-resolves.md │ ├── prefer-mock-promise-shorthand.md │ ├── no-test-prefixes.md │ ├── prefer-to-be.md │ ├── prefer-hooks-in-order.md │ ├── no-duplicate-hooks.md │ ├── prefer-equality-matcher.md │ ├── no-conditional-tests.md │ ├── no-standalone-expect.md │ ├── max-expect.md │ ├── max-expects.md │ ├── prefer-spy-on.md │ ├── no-focused-tests.md │ ├── prefer-to-be-falsy.md │ ├── prefer-todo.md │ ├── max-nested-describe.md │ ├── no-restricted-matchers.md │ ├── no-identical-title.md │ ├── no-interpolation-in-snapshots.md │ ├── prefer-to-contain.md │ ├── expect-expect.md │ ├── assertion-type.md │ ├── consistent-test-filename.md │ ├── no-alias-methods.md │ ├── require-to-throw-message.md │ ├── prefer-called-with.md │ ├── prefer-comparison-matcher.md │ ├── valid-describe-callback.md │ ├── no-large-snapshots.md │ ├── no-restricted-vi-methods.md │ ├── unbound-method.md │ ├── require-top-level-describe.md │ ├── no-done-callback.md │ ├── prefer-lowercase-title.md │ ├── valid-expect.md │ ├── consistent-test-it.md │ ├── require-hook.md │ └── no-hooks.md ├── vitest.config.ts ├── eslint-remote-tester.config.ts ├── scripts └── generate.ts ├── LICENSE └── package.json /src/utils/followAssertionChains.ts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/fixtures/file.ts: -------------------------------------------------------------------------------- 1 | console.log('Hello, world!') 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tests 3 | node_modules 4 | unbouild.config.ts 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.json 2 | **/*.md 3 | docs/*.md 4 | dist/ 5 | node_modules/ -------------------------------------------------------------------------------- /tests/fixtures/class.ts: -------------------------------------------------------------------------------- 1 | export class Function { 2 | update() {} 3 | get() {} 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | eslint-remote-tester-results/ 4 | fixtures/node_modules 5 | .idea/ 6 | -------------------------------------------------------------------------------- /fixtures/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:vitest/all" 4 | ], 5 | "rules": { 6 | "vitest/unbound-method": "off" 7 | } 8 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 2 -------------------------------------------------------------------------------- /tests/ruleTester.ts: -------------------------------------------------------------------------------- 1 | import { RuleTester } from '@typescript-eslint/rule-tester' 2 | 3 | export const ruleTester: RuleTester = new RuleTester({ 4 | parser: '@typescript-eslint/parser' 5 | }) 6 | -------------------------------------------------------------------------------- /fixtures/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | 3 | describe('foo', () => { 4 | it.todo('mdmdms') 5 | 6 | it('foo', () => { 7 | expect(true).toBeTruthy() 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@veritem" 4 | ], 5 | "rules": { 6 | "indent": [ 7 | 0, 8 | "tab" 9 | ], 10 | "no-case-declarations": 0, 11 | "no-tabs": "off", 12 | "vitest/unbound-method": "off" 13 | } 14 | } -------------------------------------------------------------------------------- /unbuild.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | entries: ['./src/index.ts'], 5 | declaration: true, 6 | clean: true, 7 | rollup: { 8 | emitCJS: true 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /src/utils/tester.ts: -------------------------------------------------------------------------------- 1 | import { RuleTester } from '@typescript-eslint/rule-tester' 2 | import { afterAll, describe, it } from 'vitest' 3 | 4 | RuleTester.afterAll = afterAll 5 | RuleTester.describe = describe 6 | RuleTester.it = it 7 | 8 | export const ruleTester: RuleTester = new RuleTester({ 9 | parser: '@typescript-eslint/parser' 10 | }) 11 | -------------------------------------------------------------------------------- /tests/fixtures/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "preserve", 4 | "target": "es5", 5 | "module": "commonjs", 6 | "strict": false, 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "es2015", 10 | "es2017", 11 | "esnext" 12 | ], 13 | "experimentalDecorators": true 14 | }, 15 | "include": [ 16 | "file.ts", 17 | "class.ts" 18 | ] 19 | } -------------------------------------------------------------------------------- /docs/rules/prefer-each.md: -------------------------------------------------------------------------------- 1 | # Prefer `each` rather than manual loops (`vitest/prefer-each`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ```js 8 | // bad 9 | for (const item of items) { 10 | describe(item, () => { 11 | expect(item).toBe('foo') 12 | }) 13 | } 14 | 15 | // good 16 | describe.each(items)('item', (item) => { 17 | expect(item).toBe('foo') 18 | }) 19 | ``` -------------------------------------------------------------------------------- /docs/rules/prefer-to-be-object.md: -------------------------------------------------------------------------------- 1 | # Prefer toBeObject() (`vitest/prefer-to-be-object`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | ```js 9 | expectTypeOf({}).not.toBeInstanceOf(Object); 10 | 11 | // should be 12 | expectTypeOf({}).not.toBeObject(); 13 | ``` -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, configDefaults } from 'vitest/config' 2 | import * as vitest from 'vitest' 3 | import { RuleTester } from '@typescript-eslint/rule-tester' 4 | 5 | RuleTester.afterAll = vitest.afterAll 6 | RuleTester.it = vitest.it 7 | RuleTester.describe = vitest.describe 8 | 9 | export default defineConfig({ 10 | test: { 11 | globals: true, 12 | include: ['tests/*.test.ts'], 13 | exclude: [...configDefaults.exclude] 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /docs/rules/no-mocks-import.md: -------------------------------------------------------------------------------- 1 | # Disallow importing from __mocks__ directory (`vitest/no-mocks-import`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule aims to prevent importing from the `__mocks__` directory. 10 | 11 | ### Fail 12 | 13 | ```ts 14 | import { foo } from '__mocks__/foo' 15 | ``` 16 | 17 | ### Pass 18 | 19 | ```ts 20 | import { foo } from 'foo' 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-have-length.md: -------------------------------------------------------------------------------- 1 | # Suggest using toHaveLength() (`vitest/prefer-to-have-length`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | ```js 9 | // bad 10 | expect(files.length).toStrictEqual(1); 11 | 12 | // good 13 | expect(files).toHaveLength(1); 14 | ``` -------------------------------------------------------------------------------- /fixtures/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fixtures", 3 | "version": "0.0.1", 4 | "description": "", 5 | "type": "module", 6 | "scripts": { 7 | "test": "vitest", 8 | "lint": "eslint . --fix" 9 | }, 10 | "keywords": [], 11 | "author": "Makuza Mugabo Verite (https://veritem.me/)", 12 | "license": "MIT", 13 | "dependencies": { 14 | "eslint-plugin-vitest": "link:../", 15 | "vitest": "^0.34.6" 16 | }, 17 | "devDependencies": { 18 | "eslint": "^8.51.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/rules/prefer-strict-equal.md: -------------------------------------------------------------------------------- 1 | # Prefer strict equal over equal (`vitest/prefer-strict-equal`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions). 6 | 7 | 8 | 9 | ```ts 10 | // bad 11 | 12 | expect(something).toEqual(somethingElse); 13 | 14 | // good 15 | expect(something).toStrictEqual(somethingElse); 16 | 17 | ``` -------------------------------------------------------------------------------- /docs/rules/no-disabled-tests.md: -------------------------------------------------------------------------------- 1 | # Disallow disabled tests (`vitest/no-disabled-tests`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule disallows disabled tests. 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```ts 14 | test.skip('foo', () => { 15 | // ... 16 | }) 17 | ``` 18 | 19 | Examples of **correct** code for this rule: 20 | 21 | ```ts 22 | test('foo', () => { 23 | // ... 24 | }) 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-be-truthy.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toBeTruthy` (`vitest/prefer-to-be-truthy`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ```js 10 | // bad 11 | expect(foo).toBe(true) 12 | expectTypeOf(foo).toBe(true) 13 | 14 | // good 15 | expect(foo).toBeTruthy() 16 | expectTypeOf(foo).toBeTruthy() 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/rules/prefer-hooks-on-top.md: -------------------------------------------------------------------------------- 1 | # Suggest having hooks before any test cases (`vitest/prefer-hooks-on-top`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | ```ts 7 | // bad 8 | 9 | describe('foo', () => { 10 | it('bar', () => { 11 | // ... 12 | }) 13 | 14 | beforeEach(() => { 15 | // ... 16 | }) 17 | }) 18 | 19 | 20 | // good 21 | 22 | describe('foo', () => { 23 | beforeEach(() => { 24 | // ... 25 | }) 26 | 27 | it('bar', () => { 28 | // ... 29 | }) 30 | }) 31 | ``` -------------------------------------------------------------------------------- /docs/rules/no-test-return-statement.md: -------------------------------------------------------------------------------- 1 | # Disallow return statements in tests (`vitest/no-test-return-statement`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ### Rule Details 8 | 9 | incorrect code for this rule: 10 | 11 | ```ts 12 | import { test } from 'vitest' 13 | 14 | test('foo', () => { 15 | return expect(1).toBe(1) 16 | }) 17 | ``` 18 | 19 | correct code for this rule: 20 | 21 | ```ts 22 | import { test } from 'vitest' 23 | 24 | test('foo', () => { 25 | expect(1).toBe(1) 26 | }) 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/rules/no-commented-out-tests.md: -------------------------------------------------------------------------------- 1 | # Disallow commented out tests (`vitest/no-commented-out-tests`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule aims to prevent commented out tests. 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```ts 14 | // test('foo', () => { 15 | // expect(1).toBe(1) 16 | // }) 17 | ``` 18 | 19 | Examples of **correct** code for this rule: 20 | 21 | ```ts 22 | test('foo', () => { 23 | expect(1).toBe(1) 24 | }) 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/rules/no-conditional-expect.md: -------------------------------------------------------------------------------- 1 | # Disallow conditional expects (`vitest/no-conditional-expect`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule aims to prevent conditional expects. 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```ts 14 | test('foo', () => { 15 | if (true) { 16 | expect(1).toBe(1) 17 | } 18 | }) 19 | ``` 20 | 21 | Examples of **correct** code for this rule: 22 | 23 | ```ts 24 | test('foo', () => { 25 | expect(1).toBe(1) 26 | }) 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/rules/no-conditional-in-test.md: -------------------------------------------------------------------------------- 1 | # Disallow conditional tests (`vitest/no-conditional-in-test`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | ### Rule Details 7 | 8 | This rule aims to prevent conditional tests. 9 | 10 | Examples of **incorrect** code for this rule: 11 | 12 | ```js 13 | test('my test', () => { 14 | if (true) { 15 | doTheThing() 16 | } 17 | }) 18 | ``` 19 | 20 | Examples of **correct** code for this rule: 21 | 22 | ```js 23 | test('my test', () => { 24 | expect(true).toBe(true) 25 | }) 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/rules/prefer-expect-resolves.md: -------------------------------------------------------------------------------- 1 | # Suggest using `expect().resolves` over `expect(await ...)` syntax (`vitest/prefer-expect-resolves`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ```ts 10 | // bad 11 | it('passes', async () => { expect(await someValue()).toBe(true); }); 12 | 13 | // good 14 | it('passes', async () => { await expect(someValue()).resolves.toBe(true); }); 15 | ``` 16 | ``` -------------------------------------------------------------------------------- /tests/consistent-test-filename.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/consistent-test-filename' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | { 7 | code: 'export {}', 8 | filename: '1.test.ts', 9 | options: [{ pattern: String.raw`.*\.test\.ts$` }] 10 | } 11 | ], 12 | invalid: [ 13 | { 14 | code: 'export {}', 15 | filename: '1.spec.ts', 16 | errors: [{ messageId: 'consistentTestFilename' }], 17 | options: [{ pattern: String.raw`.*\.test\.ts$` }] 18 | } 19 | ] 20 | }) 21 | -------------------------------------------------------------------------------- /docs/rules/prefer-mock-promise-shorthand.md: -------------------------------------------------------------------------------- 1 | # Prefer mock resolved/rejected shorthands for promises (`vitest/prefer-mock-promise-shorthand`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | ```ts 9 | // bad 10 | vi.fn().mockReturnValue(Promise.reject(42)) 11 | vi.fn().mockImplementation(() => Promise.resolve(42)) 12 | 13 | // good 14 | vi.fn().mockRejectedValue(42) 15 | vi.fn().mockResolvedValue(42) 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/rules/no-test-prefixes.md: -------------------------------------------------------------------------------- 1 | # Disallow using `test` as a prefix (`vitest/no-test-prefixes`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```js 14 | xdescribe.each([])("foo", function () {}) 15 | ``` 16 | 17 | Examples of **correct** code for this rule: 18 | 19 | ```js 20 | describe.skip.each([])("foo", function () {}) 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-be.md: -------------------------------------------------------------------------------- 1 | # Suggest using toBe() (`vitest/prefer-to-be`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ### Correct 10 | 11 | ```ts 12 | import { test } from 'vitest' 13 | 14 | test('foo', () => { 15 | expect(1).toBe(1) 16 | }) 17 | ``` 18 | 19 | ### Incorrect 20 | 21 | ```ts 22 | import { test } from 'vitest' 23 | 24 | test('foo', () => { 25 | expect(1).toEqual(1) 26 | }) 27 | ``` 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: "18" 18 | registry-url: https://registry.npmjs.org/ 19 | - run: npm install 20 | - run: npm run test --if-present 21 | - run: npx conventional-github-releaser -p angular 22 | env: 23 | CONVENTIONAL_GITHUB_RELEASER_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | -------------------------------------------------------------------------------- /docs/rules/prefer-hooks-in-order.md: -------------------------------------------------------------------------------- 1 | # Prefer having hooks in consistent order (`vitest/prefer-hooks-in-order`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ```js 8 | // consistent order of hooks 9 | ['beforeAll', 'beforeEach', 'afterEach', 'afterAll'] 10 | ``` 11 | 12 | ```js 13 | // bad 14 | afterAll(() => { 15 | removeMyDatabase(); 16 | }); 17 | beforeAll(() => { 18 | createMyDatabase(); 19 | }); 20 | ``` 21 | 22 | ```js 23 | // good 24 | beforeAll(() => { 25 | createMyDatabase(); 26 | }); 27 | afterAll(() => { 28 | removeMyDatabase(); 29 | }); 30 | ``` -------------------------------------------------------------------------------- /docs/rules/no-duplicate-hooks.md: -------------------------------------------------------------------------------- 1 | # Disallow duplicate hooks and teardown hooks (`vitest/no-duplicate-hooks`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule aims to prevent duplicate hooks and teardown hooks. 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```ts 14 | 15 | test('foo', () => { 16 | beforeEach(() => {}) 17 | beforeEach(() => {}) // duplicate beforeEach 18 | }) 19 | ``` 20 | 21 | Examples of **correct** code for this rule: 22 | 23 | ```ts 24 | test('foo', () => { 25 | beforeEach(() => {}) 26 | }) 27 | ``` 28 | 29 | -------------------------------------------------------------------------------- /docs/rules/prefer-equality-matcher.md: -------------------------------------------------------------------------------- 1 | # Suggest using the built-in quality matchers (`vitest/prefer-equality-matcher`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to enforce the use of the built-in equality matchers. 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```ts 16 | // bad 17 | expect(1 == 1).toBe(1) 18 | 19 | 20 | // bad 21 | expect(1).toEqual(1) 22 | 23 | ``` -------------------------------------------------------------------------------- /docs/rules/no-conditional-tests.md: -------------------------------------------------------------------------------- 1 | # Disallow conditional tests (`vitest/no-conditional-tests`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | Examples of **incorrect** code for this rule: 10 | 11 | ```js 12 | describe('my tests', () => { 13 | if (true) { 14 | it('is awesome', () => { 15 | doTheThing() 16 | }) 17 | } 18 | }) 19 | ``` 20 | 21 | Examples of **correct** code for this rule: 22 | 23 | ```js 24 | describe('my tests', () => { 25 | if (Math.random() > 0.5) { 26 | it('is awesome', () => { 27 | doTheThing() 28 | }) 29 | } 30 | }) 31 | ``` 32 | -------------------------------------------------------------------------------- /tests/no-identical-title.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-identical-title' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: ['it(); it();', 'test("two", () => {});'], 6 | invalid: [ 7 | { 8 | code: `describe('foo', () => { 9 | it('works', () => {}); 10 | it('works', () => {}); 11 | });`, 12 | errors: [{ messageId: 'multipleTestTitle' }] 13 | }, 14 | { 15 | code: `xdescribe('foo', () => { 16 | it('works', () => {}); 17 | it('works', () => {}); 18 | });`, 19 | errors: [{ messageId: 'multipleTestTitle' }] 20 | } 21 | ] 22 | }) 23 | -------------------------------------------------------------------------------- /docs/rules/no-standalone-expect.md: -------------------------------------------------------------------------------- 1 | # Disallow using `expect` outside of `it` or `test` blocks (`vitest/no-standalone-expect`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule aims to prevent the use of `expect` outside of `it` or `test` blocks. 10 | 11 | ### Options 12 | 13 | ```json 14 | { 15 | "vitest/no-standalone-expect": { 16 | "additionalTestBlockFunctions": ["test"] 17 | } 18 | } 19 | ``` 20 | 21 | example: 22 | 23 | ```js 24 | // invalid 25 | 26 | expect(1).toBe(1) 27 | 28 | // valid 29 | 30 | it('should be 1', () => { 31 | expect(1).toBe(1) 32 | }) 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/rules/max-expect.md: -------------------------------------------------------------------------------- 1 | # Enforce a maximum number of expect per test (`vitest/max-expect`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ### Rule Details 8 | 9 | Examples of **incorrect** code for this rule with `max: 1`: 10 | 11 | ```js 12 | test('foo', () => { 13 | expect(1).toBe(1) 14 | expect(2).toBe(2) 15 | }) 16 | ``` 17 | 18 | Examples of **correct** code for this rule: 19 | 20 | ```js 21 | test('foo', () => { 22 | expect(1).toBe(1) 23 | }) 24 | ``` 25 | 26 | ### Options 27 | 28 | > Default: `5` 29 | 30 | Maximum number of `expect` per test. 31 | 32 | ```js 33 | { 34 | max: number 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/rules/max-expects.md: -------------------------------------------------------------------------------- 1 | # Enforce a maximum number of expect per test (`vitest/max-expects`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ### Rule Details 8 | 9 | Examples of **incorrect** code for this rule with `max: 1`: 10 | 11 | ```js 12 | test('foo', () => { 13 | expect(1).toBe(1) 14 | expect(2).toBe(2) 15 | }) 16 | ``` 17 | 18 | Examples of **correct** code for this rule: 19 | 20 | ```js 21 | test('foo', () => { 22 | expect(1).toBe(1) 23 | }) 24 | ``` 25 | 26 | ### Options 27 | 28 | > Default: `5` 29 | 30 | Maximum number of `expect` per test. 31 | 32 | ```js 33 | { 34 | max: number 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/rules/prefer-spy-on.md: -------------------------------------------------------------------------------- 1 | # Suggest using `vi.spyOn` (`vitest/prefer-spy-on`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule details 10 | 11 | This rule triggers a warning if an object's property is overwritten with a vitest mock. 12 | 13 | ```ts 14 | Date.now = vi.fn(); 15 | Date.now = vi.fn(() => 10); 16 | ``` 17 | 18 | These patterns would not be considered warnings: 19 | 20 | ```ts 21 | vi.spyOn(Date, 'now'); 22 | vi.spyOn(Date, 'now').mockImplementation(() => 10); 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/rules/no-focused-tests.md: -------------------------------------------------------------------------------- 1 | # Disallow focused tests (`vitest/no-focused-tests`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```js 14 | it.only('test', () => { 15 | // ... 16 | }) 17 | 18 | test.only('it', () => { 19 | // ... 20 | }) 21 | ``` 22 | 23 | Examples of **correct** code for this rule: 24 | 25 | ```js 26 | it('test', () => { 27 | // ... 28 | }) 29 | 30 | test('it', () => { 31 | /* ... */ 32 | }) 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-be-falsy.md: -------------------------------------------------------------------------------- 1 | # Suggest using toBeFalsy() (`vitest/prefer-to-be-falsy`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to enforce the use of `toBeFalsy()` over `toBe(false)` 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```js 16 | expect(foo).toBe(false) 17 | expectTypeOf(foo).toBe(false) 18 | ``` 19 | 20 | Examples of **correct** code for this rule: 21 | 22 | ```js 23 | expect(foo).toBeFalsy() 24 | expectTypeOf(foo).toBeFalsy() 25 | ``` 26 | 27 | -------------------------------------------------------------------------------- /docs/rules/prefer-todo.md: -------------------------------------------------------------------------------- 1 | # Suggest using `test.todo` (`vitest/prefer-todo`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | When tests are empty it's better to mark them as `test.todo` as it will be highlighted in tests summary output. 10 | 11 | ### Rule details 12 | 13 | The following pattern is considered a warning: 14 | 15 | ```js 16 | test('foo'); 17 | test('foo', () => {}) 18 | test.skip('foo', () => {}) 19 | ``` 20 | 21 | The following pattern is not considered a warning: 22 | 23 | ```js 24 | test.todo('foo'); 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/rules/max-nested-describe.md: -------------------------------------------------------------------------------- 1 | # Nested describe block should be less than set max value or default value (`vitest/max-nested-describe`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | Examples of **incorrect** code for this rule with `max: 1`: 10 | 11 | ```js 12 | describe('outer', () => { 13 | describe('inner', () => { 14 | // ... 15 | }) 16 | }) 17 | ``` 18 | 19 | Examples of **correct** code for this rule: 20 | 21 | ```js 22 | describe('inner', () => { 23 | // ... 24 | }) 25 | ``` 26 | 27 | ## Options 28 | 29 | > Default: `5` 30 | 31 | Maximum number of nested `describe` blocks. 32 | 33 | ```js 34 | { 35 | max: number 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/rules/no-restricted-matchers.md: -------------------------------------------------------------------------------- 1 | # Disallow the use of certain matchers (`vitest/no-restricted-matchers`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ### Rule Details 8 | 9 | This rule disallows the use of certain matchers. 10 | 11 | 12 | ### Forexample 13 | 14 | 15 | ### Options 16 | 17 | ```json 18 | { 19 | "vitest/no-restricted-matchers": [ 20 | "error", 21 | { 22 | "not": null, 23 | } 24 | ] 25 | } 26 | ``` 27 | 28 | Examples of **incorrect** code for this rule with the above configuration 29 | 30 | ```js 31 | expect(a).not.toBe(b) 32 | ``` 33 | 34 | Examples of **correct** code for this rule with the above configuration 35 | 36 | ```js 37 | expect(a).toBe(b) 38 | ``` -------------------------------------------------------------------------------- /docs/rules/no-identical-title.md: -------------------------------------------------------------------------------- 1 | # Disallow identical titles (`vitest/no-identical-title`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```js 14 | it('is awesome', () => { 15 | /* ... */ 16 | }) 17 | 18 | it('is awesome', () => { 19 | /* ... */ 20 | }) 21 | ``` 22 | 23 | Examples of **correct** code for this rule: 24 | 25 | ```js 26 | it('is awesome', () => { 27 | /* ... */ 28 | }) 29 | 30 | it('is very awesome', () => { 31 | /* ... */ 32 | }) 33 | ``` 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | 8 | timeout-minutes: 10 9 | 10 | strategy: 11 | matrix: 12 | node-version: [16.x, 18.x] 13 | os: [ubuntu-latest, windows-latest] 14 | 15 | fail-fast: false 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - uses: pnpm/action-setup@v2 21 | with: 22 | version: 7.16.1 23 | 24 | - name: ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: pnpm 29 | 30 | - name: install 31 | run: pnpm install 32 | 33 | - name: test 34 | run: pnpm test:ci 35 | -------------------------------------------------------------------------------- /docs/rules/no-interpolation-in-snapshots.md: -------------------------------------------------------------------------------- 1 | # Disallow string interpolation in snapshots (`vitest/no-interpolation-in-snapshots`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to prevent the use of string interpolation in snapshots. 12 | 13 | ### Fail 14 | 15 | ```ts 16 | expect('foo').toMatchSnapshot(`${bar}`) 17 | expect('foo').toMatchSnapshot(`foo ${bar}`) 18 | ``` 19 | 20 | ### Pass 21 | 22 | ```ts 23 | expect('foo').toMatchSnapshot() 24 | expect('foo').toMatchSnapshot('foo') 25 | expect('foo').toMatchSnapshot(bar) 26 | ``` -------------------------------------------------------------------------------- /docs/rules/prefer-to-contain.md: -------------------------------------------------------------------------------- 1 | # Prefer using toContain() (`vitest/prefer-to-contain`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | 10 | This rule triggers a warning if `toBe()`, `toEqual()` or `toStrickEqual()` is used to assert object inclusion in an array. 11 | 12 | 13 | The following patterns are considered warnings: 14 | 15 | 16 | ```ts 17 | expect(a.includes(b)).toBe(true); 18 | expect(a.includes(b)).toEqual(true); 19 | expect(a.includes(b)).toStrictEqual(true); 20 | ``` 21 | 22 | 23 | The following patterns are not considered warnings: 24 | 25 | ```ts 26 | expect(a).toContain(b); 27 | ``` -------------------------------------------------------------------------------- /docs/rules/expect-expect.md: -------------------------------------------------------------------------------- 1 | # Enforce having expectation in test body (`vitest/expect-expect`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | Examples of **incorrect** code for this rule: 10 | 11 | ```js 12 | test('myLogic', () => { 13 | const actual = myLogic() 14 | }) 15 | ``` 16 | 17 | Examples of **correct** code for this rule: 18 | 19 | ```js 20 | test('myLogic', () => { 21 | const actual = myLogic() 22 | expect(actual).toBe(true) 23 | }) 24 | ``` 25 | 26 | ## Options 27 | 28 | > Default: `expect` 29 | 30 | Array of custom expression strings that are converted into a regular expression. 31 | 32 | ```json 33 | { 34 | "customExpressions": [ 35 | "expectValue", 36 | "mySecondExpression" 37 | ] 38 | } 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/rules/assertion-type.md: -------------------------------------------------------------------------------- 1 | # Enforce assertion type (`vitest/assertion-type`) 2 | 3 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | Examples of **incorrect** code for this rule with the default options: 10 | 11 | ```js 12 | assert(2 + 2 !== 'fish', 'two plus two is not equal to fish') 13 | ``` 14 | 15 | Examples of **correct** code for this rule with the default options: 16 | 17 | ```js 18 | expect(2 + 2).not.toEqual('fish') 19 | ``` 20 | 21 | ## Options 22 | 23 | ### `type` 24 | 25 | > Default: `"jest"` 26 | 27 | Whether to use Chai (`assert(...)`) or Jest (`expect(...)`) style assertions. 28 | 29 | ```js 30 | { 31 | type: 'chai' | 'jest' 32 | } 33 | ``` 34 | -------------------------------------------------------------------------------- /eslint-remote-tester.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'eslint-remote-tester' 2 | import { 3 | getPathIgnorePattern, 4 | getRepositories 5 | } from 'eslint-remote-tester-repositories' 6 | 7 | const config: Config = { 8 | repositories: getRepositories(), 9 | pathIgnorePattern: getPathIgnorePattern(), 10 | extensions: ['js', 'jsx', 'ts', 'tsx', 'cts', 'mts'], 11 | 12 | concurrentTasks: 3, 13 | cache: false, 14 | logLevel: 'info', 15 | 16 | eslintrc: { 17 | root: true, 18 | env: { es6: true }, 19 | parserOptions: { 20 | ecmaVersion: 2020, 21 | sourceType: 'module', 22 | ecmaFeatures: { jsx: true } 23 | }, 24 | overrides: [ 25 | { 26 | files: ['*.ts', '*.tsx', '*.mts', '*.cts'], 27 | parser: '@typescript-eslint/parser' 28 | } 29 | ], 30 | extends: ['plugin:vitest/all'] 31 | } 32 | } 33 | 34 | export default config 35 | -------------------------------------------------------------------------------- /docs/rules/consistent-test-filename.md: -------------------------------------------------------------------------------- 1 | # Forbidden .spec test file pattern (`vitest/consistent-test-filename`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ### Rule Details 8 | 9 | #### Options 10 | 11 | ```json 12 | { 13 | "type": "object", 14 | "additionalProperties": false, 15 | "properties": { 16 | "pattern": { 17 | "format": "regex", 18 | "default": ".*\\.test\\.[tj]sx?$" 19 | }, 20 | "allTestPattern": { 21 | "format": "", 22 | "default": ".*\\.(test|spec)\\.[tj]sx?$" 23 | } 24 | } 25 | } 26 | ``` 27 | 28 | ##### `allTestPattern` 29 | 30 | regex pattern for all tests files 31 | 32 | Decides whether a file is a testing file. 33 | 34 | ##### `pattern` 35 | 36 | required testing pattern 37 | 38 | `pattern` doesn't have a default value, you must provide one. 39 | -------------------------------------------------------------------------------- /docs/rules/no-alias-methods.md: -------------------------------------------------------------------------------- 1 | # Disallow alias methods (`vitest/no-alias-methods`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | 10 | ## Rule Details 11 | 12 | This rule disallows alias methods and forces the use of the original method. 13 | 14 | Examples of **incorrect** code for this rule: 15 | 16 | ```js 17 | expect(a).toBeCalled() 18 | ``` 19 | 20 | ```js 21 | expect(a).toBeCalledTimes(1) 22 | ``` 23 | 24 | 25 | Examples of **correct** code for this rule: 26 | 27 | ```js 28 | expect(a).toHaveBeenCalled() 29 | ``` 30 | 31 | ```js 32 | expect(a).toHaveBeenCalledTimes(1) 33 | ``` 34 | 35 | ## When Not To Use It 36 | 37 | If you don't care about alias methods, you can disable this rule. 38 | -------------------------------------------------------------------------------- /.github/workflows/smoke-test.yml: -------------------------------------------------------------------------------- 1 | name: Smoke test 2 | 3 | on: 4 | workflow_dispatch: # Manual trigger 5 | schedule: # Every sunday at 00:00 6 | - cron: "0 00 * * SUN" 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - uses: pnpm/action-setup@v2 15 | with: 16 | version: 7.16.1 17 | 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: 18.x 21 | cache: pnpm 22 | 23 | - name: Install, Build & Link plugin 24 | run: | 25 | pnpm install 26 | pnpm build 27 | pnpm link . 28 | pnpm link eslint-plugin-vitest 29 | 30 | - uses: AriPerkkio/eslint-remote-tester-run-action@v4 31 | with: 32 | issue-title: "Results of weekly scheduled smoke test" 33 | eslint-remote-tester-config: eslint-remote-tester.config.ts 34 | -------------------------------------------------------------------------------- /docs/rules/require-to-throw-message.md: -------------------------------------------------------------------------------- 1 | # Require toThrow() to be called with an error message (`vitest/require-to-throw-message`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | This rule triggers a warning if `toThrow()` or `toThrowError()` is used without 8 | an error message. 9 | 10 | The following patterns are considered warnings: 11 | 12 | ```js 13 | test('foo', () => { 14 | expect(() => { 15 | throw new Error('foo') 16 | }).toThrow() 17 | }) 18 | 19 | test('foo', () => { 20 | expect(() => { 21 | throw new Error('foo') 22 | }).toThrowError() 23 | }) 24 | ``` 25 | 26 | The following patterns are not considered warnings: 27 | 28 | ```js 29 | test('foo', () => { 30 | expect(() => { 31 | throw new Error('foo') 32 | }).toThrow('foo') 33 | }) 34 | 35 | test('foo', () => { 36 | expect(() => { 37 | throw new Error('foo') 38 | }).toThrowError('foo') 39 | }) 40 | ``` -------------------------------------------------------------------------------- /docs/rules/prefer-called-with.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toBeCalledWith()` or `toHaveBeenCalledWith()` (`vitest/prefer-called-with`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to enforce the use of `toBeCalledWith()` or `toHaveBeenCalledWith()` over `toBeCalled()` or `toHaveBeenCalled()`. 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```ts 16 | test('foo', () => { 17 | const mock = jest.fn() 18 | mock('foo') 19 | expect(mock).toBeCalled() 20 | expect(mock).toHaveBeenCalled() 21 | }) 22 | ``` 23 | 24 | Examples of **correct** code for this rule: 25 | 26 | ```ts 27 | test('foo', () => { 28 | const mock = jest.fn() 29 | mock('foo') 30 | expect(mock).toBeCalledWith('foo') 31 | expect(mock).toHaveBeenCalledWith('foo') 32 | }) 33 | ``` -------------------------------------------------------------------------------- /docs/rules/prefer-comparison-matcher.md: -------------------------------------------------------------------------------- 1 | # Suggest using the built-in comparison matchers (`vitest/prefer-comparison-matcher`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | This rule checks for comparisons in a test that could be replaced with one of the following built-in comparison matchers: 10 | 11 | - `toBeGreaterThan` 12 | - `toBeGreaterThanOrEqual` 13 | - `toBeLessThan` 14 | - `toBeLessThanOrEqual` 15 | 16 | Examples of **incorrect** code for this rule: 17 | 18 | ```js 19 | expect(x > 5).toBe(true); 20 | expect(x < 7).not.toEqual(true); 21 | expect(x <= y).toStrictEqual(true); 22 | ``` 23 | 24 | Examples of **correct** code for this rule: 25 | 26 | ```js 27 | expect(x).toBeGreaterThan(5); 28 | expect(x).not.toBeLessThanOrEqual(7); 29 | expect(x).toBeLessThanOrEqual(y); 30 | 31 | // special case - see below 32 | expect(x < 'Carl').toBe(true); 33 | // Rule only works on inters and big integers 34 | ``` -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | 3 | export enum DescribeAlias { 4 | 'describe' = 'describe', 5 | 'fdescribe' = 'fdescribe', 6 | 'xdescribe' = 'xdescribe', 7 | } 8 | 9 | export enum TestCaseName { 10 | 'fit' = 'fit', 11 | 'it' = 'it', 12 | 'test' = 'test', 13 | 'xit' = 'xit', 14 | 'xtest' = 'xtest', 15 | 'bench' = 'bench', 16 | } 17 | 18 | export enum HookName { 19 | 'beforeAll' = 'beforeAll', 20 | 'beforeEach' = 'beforeEach', 21 | 'afterAll' = 'afterAll', 22 | 'afterEach' = 'afterEach', 23 | } 24 | 25 | export enum ModifierName { 26 | not = 'not', 27 | rejects = 'rejects', 28 | resolves = 'resolves', 29 | } 30 | 31 | /** 32 | * Represents a `CallExpression` with a single argument. 33 | */ 34 | export interface CallExpressionWithSingleArgument< 35 | Argument extends TSESTree.CallExpression['arguments'][number] = TSESTree.CallExpression['arguments'][number], 36 | > extends TSESTree.CallExpression { 37 | arguments: [Argument]; 38 | } 39 | 40 | export enum EqualityMatcher { 41 | toBe = 'toBe', 42 | toEqual = 'toEqual', 43 | toStrictEqual = 'toStrictEqual', 44 | } 45 | -------------------------------------------------------------------------------- /scripts/generate.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import url from 'node:url' 3 | import path from 'node:path' 4 | 5 | async function generate() { 6 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) 7 | const rules = fs.readdirSync(path.resolve(__dirname, '../src/rules')) 8 | 9 | const allRules = [] 10 | const recommendedRules = [] 11 | 12 | rules.forEach(async (rule) => { 13 | const ruleName = rule.replace(/\.ts$/, '') 14 | const content = await import(path.resolve(__dirname, `../src/rules/${ruleName}.ts`)) 15 | 16 | if (content.default.meta.docs.recommended) { 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-ignore 19 | recommendedRules.push({ 20 | name: ruleName, 21 | rule: content.default 22 | }) 23 | } else { 24 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 25 | // @ts-ignore 26 | allRules.push({ 27 | name: ruleName, 28 | rule: content.default 29 | }) 30 | } 31 | }) 32 | 33 | console.log(recommendedRules) 34 | } 35 | 36 | generate() 37 | .catch((e) => { 38 | console.error(e) 39 | process.exit(1) 40 | }) 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Verité Mugabo Makuza 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/rules/prefer-hooks-on-top.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule } from '../utils' 2 | import { isTypeOfVitestFnCall } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'prefer-hooks-on-top' 5 | type MessageIds = 'noHookOnTop'; 6 | type Options = []; 7 | 8 | export default createEslintRule({ 9 | name: RULE_NAME, 10 | meta: { 11 | type: 'suggestion', 12 | docs: { 13 | description: 'Suggest having hooks before any test cases', 14 | recommended: 'error' 15 | }, 16 | messages: { 17 | noHookOnTop: 'Hooks should come before test cases' 18 | }, 19 | schema: [] 20 | }, 21 | defaultOptions: [], 22 | create(context) { 23 | const hooksContext = [false] 24 | return { 25 | CallExpression(node) { 26 | if (isTypeOfVitestFnCall(node, context, ['test'])) 27 | hooksContext[hooksContext.length - 1] = true 28 | 29 | if (hooksContext[hooksContext.length - 1] && isTypeOfVitestFnCall(node, context, ['hook'])) { 30 | context.report({ 31 | messageId: 'noHookOnTop', 32 | node 33 | }) 34 | } 35 | 36 | hooksContext.push(false) 37 | }, 38 | 'CallExpression:exit'() { 39 | hooksContext.pop() 40 | } 41 | } 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /docs/rules/valid-describe-callback.md: -------------------------------------------------------------------------------- 1 | # Enforce valid describe callback (`vitest/valid-describe-callback`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 6 | 7 | 8 | This rule validates the second parameter of a `describe()` function is a callback. 9 | 10 | - should not be async 11 | - should not contain parameters 12 | - should not contain any `return` statements 13 | 14 | The following are considered warnings: 15 | 16 | ```js 17 | // async callback functions are not allowed 18 | describe("myfunc", async () => { 19 | // 20 | }) 21 | 22 | // callback function parameters are not allowed 23 | describe("myfunc", done => { 24 | // 25 | }) 26 | 27 | 28 | describe("myfunc", () => { 29 | // no return statements are allowed in a block of a callback function 30 | return Promise.resolve().then(() => { 31 | // 32 | }) 33 | }) 34 | 35 | // returning a value from a describe block is not allowed 36 | describe("myfunc", () => { 37 | it("should do something", () => { 38 | // 39 | }) 40 | }) 41 | ``` 42 | 43 | The following are not considered warnings: 44 | 45 | ```js 46 | describe("myfunc", () => { 47 | it("should do something", () => { 48 | // 49 | }) 50 | }) 51 | ``` -------------------------------------------------------------------------------- /src/rules/no-commented-out-tests.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule } from '../utils' 3 | 4 | export const RULE_NAME = 'no-commented-out-tests' 5 | export type MESSAGE_IDS = 'noCommentedOutTests'; 6 | export type Options = []; 7 | 8 | function hasTests(node: TSESTree.Comment) { 9 | return /^\s*[xf]?(test|it|describe)(\.\w+|\[['"]\w+['"]\])?\s*\(/mu.test(node.value) 10 | } 11 | 12 | export default createEslintRule({ 13 | name: RULE_NAME, 14 | meta: { 15 | docs: { 16 | description: 'Disallow commented out tests', 17 | requiresTypeChecking: false, 18 | recommended: 'warn' 19 | }, 20 | messages: { 21 | noCommentedOutTests: 'Remove commented out tests. You may want to use `skip` or `only` instead.' 22 | }, 23 | schema: [], 24 | type: 'suggestion' 25 | }, 26 | defaultOptions: [], 27 | create(context) { 28 | const sourceCode = context.getSourceCode() 29 | 30 | function checkNodeForCommentedOutTests(node: TSESTree.Comment) { 31 | if (!hasTests(node)) 32 | return 33 | context.report({ messageId: 'noCommentedOutTests', node }) 34 | } 35 | 36 | return { 37 | Program() { 38 | const comments = sourceCode.getAllComments() 39 | comments.forEach(checkNodeForCommentedOutTests) 40 | } 41 | } 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /docs/rules/no-large-snapshots.md: -------------------------------------------------------------------------------- 1 | # Disallow large snapshots (`vitest/no-large-snapshots`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule aims to prevent large snapshots. 10 | 11 | 12 | ### Options 13 | 14 | This rule accepts an object with the following properties: 15 | 16 | * `maxSize` (default: `50`): The maximum size of a snapshot. 17 | * `inlineMaxSize` (default: `0`): The maximum size of a snapshot when it is inline. 18 | * `allowedSnapshots` (default: `[]`): The list of allowed snapshots. 19 | 20 | ### For example: 21 | 22 | ```json 23 | { 24 | "vitest/no-large-snapshots": [ 25 | "error", 26 | { 27 | "maxSize": 50, 28 | "inlineMaxSize": 0, 29 | "allowedSnapshots": [] 30 | } 31 | ] 32 | } 33 | ``` 34 | 35 | Examples of **incorrect** code for this rule with the above configuration: 36 | 37 | ```js 38 | test('large snapshot', () => { 39 | expect('a'.repeat(100)).toMatchSnapshot() 40 | }) 41 | ``` 42 | 43 | Examples of **correct** code for this rule with the above configuration: 44 | 45 | ```js 46 | test('large snapshot', () => { 47 | expect('a'.repeat(50)).toMatchSnapshot() 48 | }) 49 | ``` 50 | 51 | ## When Not To Use It 52 | 53 | If you don't want to limit the size of your snapshots, you can disable this rule. 54 | 55 | -------------------------------------------------------------------------------- /docs/rules/no-restricted-vi-methods.md: -------------------------------------------------------------------------------- 1 | # Disallow specific `vi.` methods (`vitest/no-restricted-vi-methods`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | You may wish to restrict the use of specific `vi` methods. 8 | 9 | ## Rule details 10 | 11 | This rule checks for the usage of specific methods on the `vi` object, which 12 | can be used to disallow certain patterns such as spies and mocks. 13 | 14 | ## Options 15 | 16 | Restrictions are expressed in the form of a map, with the value being either a 17 | string message to be shown, or `null` if a generic default message should be 18 | used. 19 | 20 | By default, this map is empty, meaning no `vi` methods are banned. 21 | 22 | For example: 23 | 24 | ```json 25 | { 26 | "vitest/no-restricted-vi-methods": [ 27 | "error", 28 | { 29 | "advanceTimersByTime": null, 30 | "spyOn": "Don't use spies" 31 | } 32 | ] 33 | } 34 | ``` 35 | 36 | Examples of **incorrect** code for this rule with the above configuration 37 | 38 | ```js 39 | vi.useFakeTimers(); 40 | it('calls the callback after 1 second via advanceTimersByTime', () => { 41 | // ... 42 | 43 | vi.advanceTimersByTime(1000); 44 | 45 | // ... 46 | }); 47 | 48 | test('plays video', () => { 49 | const spy = vi.spyOn(video, 'play'); 50 | 51 | // ... 52 | }); 53 | ``` -------------------------------------------------------------------------------- /src/rules/require-to-throw-message.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule, getAccessorValue } from '../utils' 2 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'require-to-throw-message' 5 | type MESSAGE_IDS = 'addErrorMessage' 6 | type Options = [] 7 | 8 | export default createEslintRule({ 9 | name: RULE_NAME, 10 | meta: { 11 | type: 'suggestion', 12 | docs: { 13 | description: 'Require toThrow() to be called with an error message', 14 | recommended: 'warn' 15 | }, 16 | schema: [], 17 | messages: { 18 | addErrorMessage: 'Add an error message to {{ matcherName }}()' 19 | } 20 | }, 21 | defaultOptions: [], 22 | create(context) { 23 | return { 24 | CallExpression(node) { 25 | const vitestFnCall = parseVitestFnCall(node, context) 26 | 27 | if (vitestFnCall?.type !== 'expect') return 28 | 29 | const { matcher } = vitestFnCall 30 | const matcherName = getAccessorValue(matcher) 31 | 32 | if (vitestFnCall.args.length === 0 && 33 | ['toThrow', 'toThrowError'].includes(matcherName) && 34 | !vitestFnCall.modifiers.some(nod => getAccessorValue(nod) === 'not') 35 | ) { 36 | context.report({ 37 | messageId: 'addErrorMessage', 38 | data: { matcherName }, 39 | node: matcher 40 | }) 41 | } 42 | } 43 | } 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /docs/rules/unbound-method.md: -------------------------------------------------------------------------------- 1 | # Enforce unbound methods are called with their expected scope (`vitest/unbound-method`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 💭 This rule requires type information. 6 | 7 | 8 | 9 | This rule extends the base [`@typescript-eslint/unbound-method`][original-rule] 10 | rule, meaning you must depend on `@typescript-eslint/eslint-plugin` for it to 11 | work. It adds support for understanding when it's ok to pass an unbound method 12 | to `expect` calls. 13 | 14 | ### How to use this rule 15 | 16 | This rule is enabled in the `all` config. 17 | 18 | 19 | ```json5 20 | { 21 | parser: '@typescript-eslint/parser', 22 | parserOptions: { 23 | project: 'tsconfig.json', 24 | ecmaVersion: 2020, 25 | sourceType: 'module', 26 | }, 27 | overrides: [ 28 | { 29 | files: ['test/**'], 30 | plugins: ['vitest'], 31 | rules: { 32 | '@typescript-eslint/unbound-method': 'off', 33 | 'vitest/unbound-method': 'error', 34 | }, 35 | }, 36 | ], 37 | rules: { 38 | '@typescript-eslint/unbound-method': 'error', 39 | }, 40 | } 41 | ``` 42 | 43 | ### Options 44 | 45 | Checkout [@typescript-eslint/unbound-method](https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/unbound-method.md) options. including `ignoreStatic` 46 | -------------------------------------------------------------------------------- /docs/rules/require-top-level-describe.md: -------------------------------------------------------------------------------- 1 | # Enforce that all tests are in a top-level describe (`vitest/require-top-level-describe`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | This rule triggers warning if a test case (`test` and `it`) or a hook (`beforeAll`, `beforeEach`, `afterEach`, `afterAll`) is not located in a top-level `describe` block. 8 | 9 | 10 | ## Options 11 | 12 | This rule accepts an object with the following properties: 13 | 14 | - `maxNumberOfTopLevelDescribes`: The maximum number of top-level tests allowed in a file. Defaults to `Infinity`. Allowing any number of top-level describe blocks. 15 | 16 | ```json 17 | { 18 | "vitest/require-top-level-describe": [ 19 | "error", 20 | { 21 | "maxNumberOfTopLevelDescribes": 2 22 | } 23 | ] 24 | } 25 | ``` 26 | 27 | 28 | 29 | The following patterns are considered warnings: 30 | 31 | ```js 32 | test('foo', () => {}) 33 | 34 | beforeEach(() => { 35 | describe('bar', () => { 36 | test('baz', () => {}) 37 | }) 38 | }) 39 | 40 | 41 | ``` 42 | 43 | The following patterns are not considered warnings: 44 | 45 | ```js 46 | describe('foo', () => { 47 | test('bar', () => {}) 48 | }) 49 | 50 | describe('foo', () => { 51 | beforeEach(() => { 52 | describe('bar', () => { 53 | test('baz', () => {}) 54 | }) 55 | }) 56 | }) 57 | 58 | ``` 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/rules/no-conditional-in-test.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule } from '../utils' 3 | import { isTypeOfVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'no-conditional-in-test' 6 | export type MESSAGE_IDS = 'noConditionalInTest'; 7 | export type Options = []; 8 | 9 | export default createEslintRule({ 10 | name: RULE_NAME, 11 | meta: { 12 | docs: { 13 | description: 'Disallow conditional tests', 14 | requiresTypeChecking: false, 15 | recommended: 'warn' 16 | }, 17 | messages: { 18 | noConditionalInTest: 'Remove conditional tests' 19 | }, 20 | schema: [], 21 | type: 'problem' 22 | }, 23 | defaultOptions: [], 24 | create(context) { 25 | let inTestCase = false 26 | 27 | const reportCondional = (node: TSESTree.Node) => { 28 | if (inTestCase) 29 | context.report({ messageId: 'noConditionalInTest', node }) 30 | } 31 | 32 | return { 33 | CallExpression(node) { 34 | if (isTypeOfVitestFnCall(node, context, ['test'])) 35 | inTestCase = true 36 | }, 37 | 'CallExpression:exit'(node) { 38 | if (isTypeOfVitestFnCall(node, context, ['test'])) 39 | inTestCase = false 40 | }, 41 | IfStatement: reportCondional, 42 | SwitchStatement: reportCondional, 43 | ConditionalExpression: reportCondional, 44 | LogicalExpression: reportCondional 45 | } 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /src/rules/no-hooks.ts: -------------------------------------------------------------------------------- 1 | import { HookName } from '../utils/types' 2 | import { createEslintRule } from '../utils' 3 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'no-hooks' 6 | export type MESSAGE_ID = 'unexpectedHook'; 7 | export type Options = []; 8 | 9 | export default createEslintRule< 10 | [Partial<{ allow: readonly HookName[] }>], 11 | MESSAGE_ID 12 | >({ 13 | name: RULE_NAME, 14 | meta: { 15 | type: 'suggestion', 16 | docs: { 17 | description: 'Disallow setup and teardown hooks', 18 | recommended: false 19 | }, 20 | schema: [{ 21 | type: 'object', 22 | properties: { 23 | allow: { 24 | type: 'array', 25 | contains: ['beforeAll', 'beforeEach', 'afterAll', 'afterEach'] 26 | } 27 | }, 28 | additionalProperties: false 29 | }], 30 | messages: { 31 | unexpectedHook: 'Unexpected \'{{ hookName }}\' hook' 32 | } 33 | }, 34 | defaultOptions: [{ allow: [] }], 35 | create(context, [{ allow = [] }]) { 36 | return { 37 | CallExpression(node) { 38 | const vitestFnCall = parseVitestFnCall(node, context) 39 | 40 | if ( 41 | vitestFnCall?.type === 'hook' && 42 | !allow.includes(vitestFnCall.name as HookName) 43 | ) { 44 | context.report({ 45 | node, 46 | messageId: 'unexpectedHook', 47 | data: { hookName: vitestFnCall.name } 48 | }) 49 | } 50 | } 51 | } 52 | } 53 | }) 54 | -------------------------------------------------------------------------------- /docs/rules/no-done-callback.md: -------------------------------------------------------------------------------- 1 | # Disallow using a callback in asynchronous tests and hooks (`vitest/no-done-callback`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to prevent the use of a callback in asynchronous tests and hooks. 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```js 16 | test('foo', (done) => { 17 | setTimeout(done, 1000) 18 | }) 19 | 20 | test('foo', (done) => { 21 | setTimeout(() => done(), 1000) 22 | }) 23 | 24 | test('foo', (done) => { 25 | setTimeout(() => { 26 | done() 27 | }, 1000) 28 | }) 29 | 30 | test('foo', (done) => { 31 | setTimeout(() => { 32 | done() 33 | }, 1000) 34 | }) 35 | ``` 36 | 37 | Examples of **correct** code for this rule: 38 | 39 | ```js 40 | test('foo', async () => { 41 | await new Promise((resolve) => setTimeout(resolve, 1000)) 42 | }) 43 | 44 | test('foo', async () => { 45 | await new Promise((resolve) => setTimeout(() => resolve(), 1000)) 46 | }) 47 | 48 | test('foo', async () => { 49 | await new Promise((resolve) => setTimeout(() => { 50 | resolve() 51 | }, 1000)) 52 | }) 53 | 54 | test('foo', async () => { 55 | await new Promise((resolve) => setTimeout(() => { 56 | resolve() 57 | }, 1000)) 58 | }) 59 | ``` 60 | -------------------------------------------------------------------------------- /docs/rules/prefer-lowercase-title.md: -------------------------------------------------------------------------------- 1 | # Enforce lowercase titles (`vitest/prefer-lowercase-title`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```js 14 | test('It works', () => { 15 | // ... 16 | }) 17 | ``` 18 | 19 | Examples of **correct** code for this rule: 20 | 21 | ```js 22 | test('it works', () => { 23 | // ... 24 | }) 25 | ``` 26 | 27 | 28 | ### Options 29 | 30 | ```json 31 | { 32 | "type":"object", 33 | "properties":{ 34 | "ignore":{ 35 | "type":"array", 36 | "items":{ 37 | "enum":[ 38 | "describe", 39 | "test", 40 | "it" 41 | ] 42 | }, 43 | "additionalProperties":false 44 | }, 45 | "allowedPrefixes":{ 46 | "type":"array", 47 | "items":{ 48 | "type":"string" 49 | }, 50 | "additionalItems":false 51 | }, 52 | "ignoreTopLevelDescribe":{ 53 | "type":"boolean", 54 | "default":false 55 | }, 56 | "lowercaseFirstCharacterOnly":{ 57 | "type":"boolean", 58 | "default":true 59 | } 60 | }, 61 | "additionalProperties":false 62 | } 63 | ``` -------------------------------------------------------------------------------- /tests/unbound-method.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { RuleTester } from '@typescript-eslint/rule-tester' 3 | import unboundMethod from '../src/rules/unbound-method' 4 | 5 | const rootPath = path.join(__dirname, './fixtures') 6 | 7 | const ruleTester = new RuleTester({ 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | tsconfigRootDir: rootPath, 11 | project: './tsconfig.json', 12 | sourceType: 'module' 13 | } 14 | }) 15 | 16 | ruleTester.run('unbound-method', unboundMethod, { 17 | valid: [ 18 | { 19 | code: `class MyClass { 20 | public logArrowBound = (): void => { 21 | console.log(bound); 22 | }; 23 | 24 | public logManualBind(): void { 25 | console.log(this); 26 | } 27 | } 28 | 29 | const instance = new MyClass(); 30 | const logArrowBound = instance.logArrowBound; 31 | const logManualBind = instance.logManualBind.bind(instance); 32 | 33 | logArrowBound(); 34 | logManualBind();`, 35 | skip: true 36 | } 37 | ], 38 | invalid: [ 39 | { 40 | code: ` 41 | class Console { 42 | log(str) { 43 | process.stdout.write(str); 44 | } 45 | } 46 | 47 | const console = new Console(); 48 | 49 | Promise.resolve().then(console.log); 50 | `, 51 | skip: true, 52 | errors: [ 53 | { 54 | line: 10, 55 | messageId: 'unboundWithoutThisAnnotation' 56 | } 57 | ] 58 | } 59 | ] 60 | }) 61 | -------------------------------------------------------------------------------- /src/rules/no-interpolation-in-snapshots.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils' 2 | import { createEslintRule, getAccessorValue } from '../utils' 3 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'no-interpolation-in-snapshots' 6 | export type MESSAGE_ID = 'noInterpolationInSnapshots' 7 | export type Options = [] 8 | 9 | export default createEslintRule({ 10 | name: RULE_NAME, 11 | meta: { 12 | type: 'problem', 13 | docs: { 14 | description: 'Disallow string interpolation in snapshots', 15 | recommended: 'error' 16 | }, 17 | fixable: 'code', 18 | schema: [], 19 | messages: { 20 | noInterpolationInSnapshots: 'Do not use string interpolation in snapshots' 21 | } 22 | }, 23 | defaultOptions: [], 24 | create(context) { 25 | return { 26 | CallExpression(node) { 27 | const vitestFnCall = parseVitestFnCall(node, context) 28 | 29 | if (vitestFnCall?.type !== 'expect') 30 | return 31 | 32 | if (['toMatchInlineSnapshot', 33 | 'toThrowErrorMatchingInlineSnapshot'].includes(getAccessorValue(vitestFnCall.matcher))) { 34 | vitestFnCall.args.forEach(argument => { 35 | if (argument.type === AST_NODE_TYPES.TemplateLiteral && argument.expressions.length > 0) { 36 | context.report({ 37 | messageId: 'noInterpolationInSnapshots', 38 | node: argument 39 | }) 40 | } 41 | }) 42 | } 43 | } 44 | } 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /src/rules/prefer-strict-equal.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule, isSupportedAccessor, replaceAccessorFixer } from '../utils' 2 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | import { EqualityMatcher } from '../utils/types' 4 | 5 | export const RULE_NAME = 'prefer-strict-equal' 6 | export type MESSAGE_IDS = 'useToStrictEqual' | 'suggestReplaceWithStrictEqual' 7 | type Options = [] 8 | 9 | export default createEslintRule({ 10 | name: RULE_NAME, 11 | meta: { 12 | type: 'suggestion', 13 | docs: { 14 | description: 'Prefer strict equal over equal', 15 | recommended: 'warn' 16 | }, 17 | messages: { 18 | useToStrictEqual: 'Use `toStrictEqual()` instead', 19 | suggestReplaceWithStrictEqual: 'Replace with `toStrictEqual()`' 20 | }, 21 | schema: [], 22 | hasSuggestions: true 23 | }, 24 | defaultOptions: [], 25 | create(context) { 26 | return { 27 | CallExpression(node) { 28 | const vitestFnCall = parseVitestFnCall(node, context) 29 | 30 | if (vitestFnCall?.type !== 'expect') return 31 | 32 | const { matcher } = vitestFnCall 33 | 34 | if (isSupportedAccessor(matcher, 'toEqual')) { 35 | context.report({ 36 | messageId: 'useToStrictEqual', 37 | node: matcher, 38 | suggest: [ 39 | { 40 | messageId: 'suggestReplaceWithStrictEqual', 41 | fix: fixer => [ 42 | replaceAccessorFixer(fixer, matcher, EqualityMatcher.toStrictEqual) 43 | ] 44 | } 45 | ] 46 | }) 47 | } 48 | } 49 | } 50 | } 51 | }) 52 | -------------------------------------------------------------------------------- /src/rules/no-mocks-import.ts: -------------------------------------------------------------------------------- 1 | import { posix } from 'node:path' 2 | import { TSESTree } from '@typescript-eslint/utils' 3 | import { createEslintRule, getStringValue, isStringNode } from '../utils' 4 | 5 | const mocksDirName = '__mocks__' 6 | 7 | const isMockPath = (path: string) => path.split(posix.sep).includes(mocksDirName) 8 | 9 | const isMockImportLiteral = (expression: TSESTree.CallExpressionArgument) => isStringNode(expression) && isMockPath(getStringValue(expression)) 10 | 11 | export const RULE_NAME = 'no-mocks-import' 12 | type MESSAGE_IDS = 'noMocksImport'; 13 | type Options = [] 14 | 15 | export default createEslintRule({ 16 | name: RULE_NAME, 17 | meta: { 18 | type: 'problem', 19 | docs: { 20 | description: 'Disallow importing from __mocks__ directory', 21 | recommended: 'error' 22 | }, 23 | messages: { 24 | noMocksImport: `Mocks should not be manually imported from a ${mocksDirName} directory. Instead use \`jest.mock\` and import from the original module path.` 25 | }, 26 | schema: [] 27 | }, 28 | defaultOptions: [], 29 | create(context) { 30 | return { 31 | ImportDeclaration(node: TSESTree.ImportDeclaration) { 32 | if (isMockImportLiteral(node.source)) 33 | context.report({ node, messageId: 'noMocksImport' }) 34 | }, 35 | 'CallExpression[callee.name="require"]'(node: TSESTree.CallExpression) { 36 | const [args] = node.arguments 37 | 38 | if (args && isMockImportLiteral(args)) 39 | context.report({ node: args, messageId: 'noMocksImport' }) 40 | } 41 | } 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /tests/prefer-expect-resolves.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/prefer-expect-resolves' 2 | import { ruleTester } from './ruleTester' 3 | 4 | const messageId = 'expectResolves' 5 | 6 | ruleTester.run(RULE_NAME, rule, { 7 | valid: [ 8 | 'expect.hasAssertions()', 9 | `it('passes', async () => { 10 | await expect(someValue()).resolves.toBe(true); 11 | });`, 12 | `it('is true', async () => { 13 | const myPromise = Promise.resolve(true); 14 | 15 | await expect(myPromise).resolves.toBe(true); 16 | }); 17 | `, 18 | `it('errors', async () => { 19 | await expect(Promise.reject(new Error('oh noes!'))).rejects.toThrowError( 20 | 'oh noes!', 21 | ); 22 | });`, 23 | 'expect().nothing();' 24 | ], 25 | invalid: [ 26 | { 27 | code: 'it(\'passes\', async () => { expect(await someValue()).toBe(true); });', 28 | output: 29 | 'it(\'passes\', async () => { await expect(someValue()).resolves.toBe(true); });', 30 | errors: [ 31 | { 32 | messageId 33 | } 34 | ] 35 | }, 36 | { 37 | code: 'it(\'is true\', async () => { const myPromise = Promise.resolve(true); expect(await myPromise).toBe(true); });', 38 | output: 39 | 'it(\'is true\', async () => { const myPromise = Promise.resolve(true); await expect(myPromise).resolves.toBe(true); });', 40 | errors: [ 41 | { 42 | messageId, 43 | endColumn: 92, 44 | column: 77 45 | } 46 | ] 47 | } 48 | ] 49 | }) 50 | -------------------------------------------------------------------------------- /src/rules/prefer-expect-resolves.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils' 2 | import { createEslintRule } from '../utils' 3 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'prefer-expect-resolves' 6 | type MESSAGE_IDS = 'expectResolves' 7 | type Options = [] 8 | 9 | export default createEslintRule({ 10 | name: RULE_NAME, 11 | meta: { 12 | type: 'suggestion', 13 | docs: { 14 | description: 'Suggest using `expect().resolves` over `expect(await ...)` syntax', 15 | recommended: 'warn' 16 | }, 17 | fixable: 'code', 18 | messages: { 19 | expectResolves: 'Use `expect().resolves` instead' 20 | }, 21 | schema: [] 22 | }, 23 | defaultOptions: [], 24 | create: (context) => ({ 25 | CallExpression(node) { 26 | const vitestFnCall = parseVitestFnCall(node, context) 27 | 28 | if (vitestFnCall?.type !== 'expect') return 29 | 30 | const { parent } = vitestFnCall.head.node 31 | 32 | if (parent?.type !== AST_NODE_TYPES.CallExpression) 33 | return 34 | 35 | const [awaitNode] = parent.arguments 36 | 37 | if (awaitNode?.type === AST_NODE_TYPES.AwaitExpression) { 38 | context.report({ 39 | node: awaitNode, 40 | messageId: 'expectResolves', 41 | fix(fixer) { 42 | return [ 43 | fixer.insertTextBefore(parent, 'await '), 44 | fixer.removeRange([ 45 | awaitNode.range[0], 46 | awaitNode.argument.range[0] 47 | ]), 48 | fixer.insertTextAfter(parent, '.resolves') 49 | ] 50 | } 51 | }) 52 | } 53 | } 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/rules/prefer-called-with.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule, getAccessorValue } from '../utils' 2 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'prefer-called-with' 5 | type MESSAGE_IDS = 'preferCalledWith'; 6 | type Options = []; 7 | 8 | export default createEslintRule({ 9 | name: RULE_NAME, 10 | meta: { 11 | docs: { 12 | description: 13 | 'Suggest using `toBeCalledWith()` or `toHaveBeenCalledWith()`', 14 | recommended: 'warn' 15 | }, 16 | messages: { 17 | preferCalledWith: 'Prefer {{ matcherName }}With(/* expected args */)' 18 | }, 19 | type: 'suggestion', 20 | fixable: 'code', 21 | schema: [] 22 | }, 23 | defaultOptions: [], 24 | create(context) { 25 | return { 26 | CallExpression(node) { 27 | const vitestFnCall = parseVitestFnCall(node, context) 28 | 29 | if (vitestFnCall?.type !== 'expect') return 30 | 31 | if ( 32 | vitestFnCall.modifiers.some( 33 | (node) => getAccessorValue(node) === 'not' 34 | ) 35 | ) 36 | return 37 | 38 | const { matcher } = vitestFnCall 39 | const matcherName = getAccessorValue(matcher) 40 | 41 | if (['toBeCalled', 'toHaveBeenCalled'].includes(matcherName)) { 42 | context.report({ 43 | data: { matcherName }, 44 | messageId: 'preferCalledWith', 45 | node: matcher, 46 | fix: (fixer) => [fixer.replaceText(matcher, `${matcherName}With`)] 47 | }) 48 | } 49 | } 50 | } 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /src/rules/no-duplicate-hooks.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule } from '../utils' 2 | import { isTypeOfVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'no-duplicate-hooks' 5 | export type MESSAGE_IDS = 'noDuplicateHooks'; 6 | export type Options = [] 7 | 8 | export default createEslintRule({ 9 | name: RULE_NAME, 10 | meta: { 11 | docs: { 12 | recommended: 'error', 13 | description: 'Disallow duplicate hooks and teardown hooks', 14 | requiresTypeChecking: false 15 | }, 16 | messages: { 17 | noDuplicateHooks: 'Duplicate {{hook}} in describe block.' 18 | }, 19 | schema: [], 20 | type: 'suggestion' 21 | }, 22 | defaultOptions: [], 23 | create(context) { 24 | const hooksContexts: Array> = [{}] 25 | 26 | return { 27 | CallExpression(node) { 28 | const vitestFnCall = parseVitestFnCall(node, context) 29 | 30 | if (vitestFnCall?.type === 'describe') 31 | hooksContexts.push({}) 32 | 33 | if (vitestFnCall?.type !== 'hook') 34 | return 35 | 36 | const currentLayer = hooksContexts[hooksContexts.length - 1] 37 | 38 | currentLayer[vitestFnCall.name] ||= 0 39 | currentLayer[vitestFnCall.name] += 1 40 | 41 | if (currentLayer[vitestFnCall.name] > 1) { 42 | context.report({ 43 | messageId: 'noDuplicateHooks', 44 | data: { hook: vitestFnCall.name }, 45 | node 46 | }) 47 | } 48 | }, 49 | 'CallExpression:exit'(node) { 50 | if (isTypeOfVitestFnCall(node, context, ['describe'])) 51 | hooksContexts.pop() 52 | } 53 | } 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /tests/no-focused-tests.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-focused-tests' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: ['it("test", () => {});', 'describe("test group", () => {});'], 6 | 7 | invalid: [ 8 | { 9 | code: 'it.only("test", () => {});', 10 | errors: [ 11 | { 12 | column: 4, 13 | endColumn: 8, 14 | endLine: 1, 15 | line: 1, 16 | messageId: 'noFocusedTests' 17 | } 18 | ], 19 | output: 'it.only("test", () => {});' 20 | }, 21 | { 22 | code: 'describe.only("test", () => {});', 23 | errors: [ 24 | { 25 | column: 10, 26 | endColumn: 14, 27 | endLine: 1, 28 | line: 1, 29 | messageId: 'noFocusedTests' 30 | } 31 | ], 32 | output: 'describe.only("test", () => {});' 33 | }, 34 | { 35 | code: 'test.only("test", () => {});', 36 | errors: [ 37 | { 38 | column: 6, 39 | endColumn: 10, 40 | endLine: 1, 41 | line: 1, 42 | messageId: 'noFocusedTests' 43 | } 44 | ], 45 | output: 'test.only("test", () => {});' 46 | }, 47 | { 48 | code: 'it.only.each([])("test", () => {});', 49 | errors: [ 50 | { 51 | column: 4, 52 | endColumn: 8, 53 | endLine: 1, 54 | line: 1, 55 | messageId: 'noFocusedTests' 56 | } 57 | ], 58 | output: 'it.only.each([])("test", () => {});' 59 | } 60 | ] 61 | }) 62 | -------------------------------------------------------------------------------- /tests/no-mocks-import.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-mocks-import' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'import something from "something"', 7 | 'require("somethingElse")', 8 | 'require("./__mocks__.js")', 9 | 'require("./__mocks__x")', 10 | 'require("./__mocks__x/x")', 11 | 'require("./x__mocks__")', 12 | 'require("./x__mocks__/x")', 13 | 'require()', 14 | 'var path = "./__mocks__.js"; require(path)', 15 | 'entirelyDifferent(fn)' 16 | ], 17 | invalid: [ 18 | { 19 | code: 'require("./__mocks__")', 20 | errors: [{ endColumn: 22, column: 9, messageId: 'noMocksImport' }] 21 | }, 22 | { 23 | code: 'require("./__mocks__/")', 24 | errors: [{ endColumn: 23, column: 9, messageId: 'noMocksImport' }] 25 | }, 26 | { 27 | code: 'require("./__mocks__/index")', 28 | errors: [{ endColumn: 28, column: 9, messageId: 'noMocksImport' }] 29 | }, 30 | { 31 | code: 'require("__mocks__")', 32 | errors: [{ endColumn: 20, column: 9, messageId: 'noMocksImport' }] 33 | }, 34 | { 35 | code: 'require("__mocks__/")', 36 | errors: [{ endColumn: 21, column: 9, messageId: 'noMocksImport' }] 37 | }, 38 | { 39 | code: 'require("__mocks__/index")', 40 | errors: [{ endColumn: 26, column: 9, messageId: 'noMocksImport' }] 41 | }, 42 | { 43 | code: 'import thing from "./__mocks__/index"', 44 | parserOptions: { sourceType: 'module' }, 45 | errors: [{ endColumn: 38, column: 1, messageId: 'noMocksImport' }] 46 | } 47 | ] 48 | }) 49 | -------------------------------------------------------------------------------- /src/rules/no-restricted-vi-methods.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule, getAccessorValue } from '../utils' 2 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'no-restricted-vi-methods' 5 | export type MESSAGE_ID = 'restrictedViMethod' | 'restrictedViMethodWithMessage' 6 | export type Options = [Record] 7 | 8 | export default createEslintRule({ 9 | name: RULE_NAME, 10 | meta: { 11 | type: 'suggestion', 12 | docs: { 13 | description: 'Disallow specific `vi.` methods', 14 | recommended: false 15 | }, 16 | schema: [{ 17 | type: 'object', 18 | additionalProperties: { type: ['string', 'null'] } 19 | }], 20 | messages: { 21 | restrictedViMethod: 'Use of `{{ restriction }}` is disallowed', 22 | restrictedViMethodWithMessage: '{{ message }}' 23 | } 24 | }, 25 | defaultOptions: [{}], 26 | create(context, [restrictedMethods]) { 27 | return { 28 | CallExpression(node) { 29 | const vitestFnCall = parseVitestFnCall(node, context) 30 | 31 | if (vitestFnCall?.type !== 'vi' || vitestFnCall.members.length === 0) 32 | return 33 | 34 | const method = getAccessorValue(vitestFnCall.members[0]) 35 | 36 | if (method in restrictedMethods) { 37 | const message = restrictedMethods[method] 38 | 39 | context.report({ 40 | messageId: message 41 | ? 'restrictedViMethodWithMessage' 42 | : 'restrictedViMethod', 43 | data: { message, restriction: method }, 44 | loc: { 45 | start: vitestFnCall.members[0].loc.start, 46 | end: vitestFnCall.members[vitestFnCall.members.length - 1].loc.end 47 | } 48 | }) 49 | } 50 | } 51 | } 52 | } 53 | }) 54 | -------------------------------------------------------------------------------- /docs/rules/valid-expect.md: -------------------------------------------------------------------------------- 1 | # Enforce valid `expect()` usage (`vitest/valid-expect`) 2 | 3 | 💼 This rule is enabled in the ✅ `recommended` config. 4 | 5 | 6 | 7 | This rule triggers a warning if `expect` is called with no argument or with more than one argument. You change that behavior by setting the `minArgs` and `maxArgs` options. 8 | 9 | ### Options 10 | 11 | 1. `alwaysAwait` 12 | 13 | - Type: `boolean` 14 | - Default: `false` 15 | 16 | - Enforce `expect` to be called with an `await` expression. 17 | 18 | ```js 19 | // ✅ good 20 | await expect(Promise.resolve(1)).resolves.toBe(1) 21 | await expect(Promise.reject(1)).rejects.toBe(1) 22 | 23 | // ❌ bad 24 | expect(Promise.resolve(1)).resolves.toBe(1) 25 | expect(Promise.reject(1)).rejects.toBe(1) 26 | ``` 27 | 28 | 29 | 2. `asyncMatchers` 30 | 31 | - Type: `string[]` 32 | - Default: `[]` 33 | 34 | 35 | ```js 36 | { 37 | "vitest/valid-expect": ["error", { 38 | "asyncMatchers": ["toBeResolved", "toBeRejected"] 39 | }] 40 | } 41 | ``` 42 | 43 | avoid using asyncMatchers with `expect`: 44 | 45 | 46 | 47 | 3. `minArgs` 48 | 49 | - Type: `number` 50 | - Default: `1` 51 | 52 | - Enforce `expect` to be called with at least `minArgs` arguments. 53 | 54 | ```js 55 | // ✅ good 56 | expect(1).toBe(1) 57 | expect(1, 2).toBe(1) 58 | expect(1, 2, 3).toBe(1) 59 | 60 | // ❌ bad 61 | expect().toBe(1) 62 | expect(1).toBe() 63 | ``` 64 | 65 | 4. `maxArgs` 66 | 67 | - Type: `number` 68 | - Default: `1` 69 | 70 | - Enforce `expect` to be called with at most `maxArgs` arguments. 71 | 72 | ```js 73 | // ✅ good 74 | expect(1).toBe(1) 75 | 76 | 77 | // ❌ bad 78 | expect(1, 2, 3, 4).toBe(1) 79 | ``` 80 | 81 | -------------------------------------------------------------------------------- /src/rules/no-conditional-tests.ts: -------------------------------------------------------------------------------- 1 | // Got inspirations from https://github.com/shokai/eslint-plugin-if-in-test 2 | 3 | import { TSESTree } from '@typescript-eslint/utils/dist/ts-estree' 4 | import { createEslintRule } from '../utils' 5 | 6 | export const RULE_NAME = 'no-conditional-tests' 7 | export type MESSAGE_ID = 'noConditionalTests'; 8 | 9 | export default createEslintRule<[], MESSAGE_ID>({ 10 | name: RULE_NAME, 11 | meta: { 12 | type: 'problem', 13 | docs: { 14 | description: 'Disallow conditional tests', 15 | recommended: false 16 | }, 17 | schema: [], 18 | messages: { 19 | noConditionalTests: 'Avoid using conditionals in a test.' 20 | } 21 | }, 22 | defaultOptions: [], 23 | create(context) { 24 | let isInTestBlock = false 25 | 26 | function checkIfItsUnderTestOrItBlock(node: TSESTree.Node) { 27 | if ( 28 | node.type === 'CallExpression' && 29 | node.callee.type === 'Identifier' && 30 | (node.callee.name === 'it' || node.callee.name === 'test') 31 | ) 32 | return true 33 | } 34 | 35 | function reportConditional(node: TSESTree.Node) { 36 | if (isInTestBlock) { 37 | context.report({ 38 | node, 39 | messageId: 'noConditionalTests' 40 | }) 41 | } 42 | } 43 | 44 | return { 45 | CallExpression: function (node: TSESTree.CallExpression) { 46 | if (checkIfItsUnderTestOrItBlock(node)) isInTestBlock = true 47 | }, 48 | 'CallExpression:exit': function (node: TSESTree.CallExpression) { 49 | if (checkIfItsUnderTestOrItBlock(node)) isInTestBlock = false 50 | }, 51 | IfStatement: reportConditional, 52 | SwitchStatement: reportConditional, 53 | LogicalExpression: reportConditional, 54 | ConditionalExpression: reportConditional 55 | } 56 | } 57 | }) 58 | -------------------------------------------------------------------------------- /tests/prefer-strict-equal.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/prefer-strict-equal' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'expect(something).toStrictEqual(somethingElse);', 7 | 'a().toEqual(\'b\')', 8 | 'expect(a);' 9 | ], 10 | invalid: [ 11 | { 12 | code: 'expect(something).toEqual(somethingElse);', 13 | errors: [ 14 | { 15 | messageId: 'useToStrictEqual', 16 | column: 19, 17 | line: 1, 18 | suggestions: [ 19 | { 20 | messageId: 'suggestReplaceWithStrictEqual', 21 | output: 'expect(something).toStrictEqual(somethingElse);' 22 | } 23 | ] 24 | } 25 | ] 26 | }, 27 | { 28 | code: 'expect(something).toEqual(somethingElse,);', 29 | parserOptions: { ecmaVersion: 2017 }, 30 | errors: [ 31 | { 32 | messageId: 'useToStrictEqual', 33 | column: 19, 34 | line: 1, 35 | suggestions: [ 36 | { 37 | messageId: 'suggestReplaceWithStrictEqual', 38 | output: 'expect(something).toStrictEqual(somethingElse,);' 39 | } 40 | ] 41 | } 42 | ] 43 | }, 44 | { 45 | code: 'expect(something)["toEqual"](somethingElse);', 46 | errors: [ 47 | { 48 | messageId: 'useToStrictEqual', 49 | column: 19, 50 | line: 1, 51 | suggestions: [ 52 | { 53 | messageId: 'suggestReplaceWithStrictEqual', 54 | output: 'expect(something)[\'toStrictEqual\'](somethingElse);' 55 | } 56 | ] 57 | } 58 | ] 59 | } 60 | ] 61 | }) 62 | -------------------------------------------------------------------------------- /src/rules/no-test-prefixes.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils' 2 | import { createEslintRule, getAccessorValue } from '../utils' 3 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'no-test-prefixes' 6 | export type MESSAGE_IDS = 'usePreferredName' 7 | export type Options = [] 8 | 9 | export default createEslintRule({ 10 | name: RULE_NAME, 11 | meta: { 12 | docs: { 13 | description: 'Disallow using `test` as a prefix', 14 | recommended: 'warn' 15 | }, 16 | type: 'suggestion', 17 | messages: { 18 | usePreferredName: 'Use "{{preferredNodeName}}" instead' 19 | }, 20 | fixable: 'code', 21 | schema: [] 22 | }, 23 | defaultOptions: [], 24 | create(context) { 25 | return { 26 | CallExpression(node) { 27 | const vitestFnCall = parseVitestFnCall(node, context) 28 | 29 | if (vitestFnCall?.type !== 'describe' && vitestFnCall?.type !== 'test') 30 | return 31 | 32 | if (vitestFnCall.name[0] !== 'f' && vitestFnCall.name[0] !== 'x') 33 | return 34 | 35 | const preferredNodeName = [ 36 | vitestFnCall.name.slice(1), 37 | vitestFnCall.name[0] === 'f' ? 'only' : 'skip', 38 | ...vitestFnCall.members.map(m => getAccessorValue(m)) 39 | ].join('.') 40 | 41 | const funcNode = 42 | node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression 43 | ? node.callee.tag 44 | : node.callee.type === AST_NODE_TYPES.CallExpression 45 | ? node.callee.callee 46 | : node.callee 47 | 48 | context.report({ 49 | messageId: 'usePreferredName', 50 | node: node.callee, 51 | data: { preferredNodeName }, 52 | fix: fixer => [fixer.replaceText(funcNode, preferredNodeName)] 53 | }) 54 | } 55 | } 56 | } 57 | }) 58 | -------------------------------------------------------------------------------- /docs/rules/consistent-test-it.md: -------------------------------------------------------------------------------- 1 | # Prefer test or it but not both (`vitest/consistent-test-it`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ### Rule Details 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```js 14 | test('it works', () => { 15 | // ... 16 | }) 17 | 18 | it('it works', () => { 19 | // ... 20 | }) 21 | ``` 22 | 23 | Examples of **correct** code for this rule: 24 | 25 | ```js 26 | test('it works', () => { 27 | // ... 28 | }) 29 | ``` 30 | 31 | ```js 32 | test('it works', () => { 33 | // ... 34 | }) 35 | ``` 36 | 37 | #### Options 38 | 39 | ```json 40 | { 41 | "type":"object", 42 | "properties":{ 43 | "fn":{ 44 | "enum":[ 45 | "it", 46 | "test" 47 | ] 48 | }, 49 | "withinDescribe":{ 50 | "enum":[ 51 | "it", 52 | "test" 53 | ] 54 | } 55 | }, 56 | "additionalProperties":false 57 | } 58 | ``` 59 | 60 | ##### `fn` 61 | 62 | Decides whether to prefer `test` or `it`. 63 | 64 | ##### `withinDescribe` 65 | 66 | Decides whether to prefer `test` or `it` when used within a `describe` block. 67 | 68 | ```js 69 | /*eslint vitest/consistent-test-it: ["error", {"fn": "test"}]*/ 70 | 71 | test('it works', () => { // <-- Valid 72 | // ... 73 | }) 74 | 75 | test.only('it works', () => { // <-- Valid 76 | // ... 77 | }) 78 | 79 | 80 | it('it works', () => { // <-- Invalid 81 | // ... 82 | }) 83 | 84 | it.only('it works', () => { // <-- Invalid 85 | // ... 86 | }) 87 | ``` 88 | 89 | The default configuration is top level `test` and all tests nested with `describe` to use `it`. 90 | 91 | -------------------------------------------------------------------------------- /src/rules/prefer-to-be-falsy.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule, getAccessorValue } from '../utils' 3 | import { getFirstMatcherArg, parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | import { EqualityMatcher } from '../utils/types' 5 | 6 | export type MESSAGE_IDS = 'preferToBeFalsy'; 7 | export const RULE_NAME = 'prefer-to-be-falsy' 8 | type Options = [] 9 | 10 | interface FalseLiteral extends TSESTree.BooleanLiteral { 11 | value: false; 12 | } 13 | 14 | const isFalseLiteral = (node: TSESTree.Node): node is FalseLiteral => 15 | node.type === AST_NODE_TYPES.Literal && node.value === false 16 | 17 | export default createEslintRule({ 18 | name: RULE_NAME, 19 | meta: { 20 | type: 'suggestion', 21 | docs: { 22 | description: 'Suggest using toBeFalsy()', 23 | recommended: 'warn' 24 | }, 25 | fixable: 'code', 26 | schema: [], 27 | messages: { 28 | preferToBeFalsy: 'Prefer using toBeFalsy()' 29 | } 30 | }, 31 | defaultOptions: [], 32 | create(context) { 33 | return { 34 | CallExpression(node) { 35 | const vitestFnCall = parseVitestFnCall(node, context) 36 | 37 | if (!(vitestFnCall?.type === 'expect' || vitestFnCall?.type === 'expectTypeOf')) return 38 | 39 | if (vitestFnCall.args.length === 1 && 40 | isFalseLiteral(getFirstMatcherArg(vitestFnCall)) && 41 | // eslint-disable-next-line no-prototype-builtins 42 | EqualityMatcher.hasOwnProperty(getAccessorValue(vitestFnCall.matcher))) { 43 | context.report({ 44 | node: vitestFnCall.matcher, 45 | messageId: 'preferToBeFalsy', 46 | fix: fixer => [ 47 | fixer.replaceText(vitestFnCall.matcher, 'toBeFalsy'), 48 | fixer.remove(vitestFnCall.args[0]) 49 | ] 50 | }) 51 | } 52 | } 53 | } 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /tests/no-test-return-statement.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-test-return-statement' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'it("noop", function () {});', 7 | 'test("noop", () => {});', 8 | 'test("one", () => expect(1).toBe(1));', 9 | 'test("empty")', 10 | `it("one", myTest); 11 | function myTest() { 12 | expect(1).toBe(1); 13 | }`, 14 | `it("one", myTest); 15 | function myTest() { 16 | expect(1).toBe(1); 17 | }`, 18 | ` 19 | it("one", () => expect(1).toBe(1)); 20 | function myHelper() {} 21 | ` 22 | ], 23 | invalid: [ 24 | { 25 | code: `test("one", () => { 26 | return expect(1).toBe(1); 27 | });`, 28 | errors: [ 29 | { 30 | messageId: 'noTestReturnStatement', 31 | column: 7, 32 | line: 2 33 | } 34 | ] 35 | }, 36 | { 37 | code: `it("one", function () { 38 | return expect(1).toBe(1); 39 | });`, 40 | errors: [ 41 | { 42 | messageId: 'noTestReturnStatement', 43 | column: 7, 44 | line: 2 45 | } 46 | ] 47 | }, 48 | { 49 | code: `it.skip("one", function () { 50 | return expect(1).toBe(1); 51 | });`, 52 | errors: [ 53 | { 54 | messageId: 'noTestReturnStatement', 55 | column: 7, 56 | line: 2 57 | } 58 | ] 59 | }, 60 | { 61 | code: `it("one", myTest); 62 | function myTest () { 63 | return expect(1).toBe(1); 64 | }`, 65 | errors: [ 66 | { 67 | messageId: 'noTestReturnStatement', 68 | column: 8, 69 | line: 3 70 | } 71 | ] 72 | } 73 | ] 74 | }) 75 | -------------------------------------------------------------------------------- /tests/prefer-called-with.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/prefer-called-with' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'expect(fn).toBeCalledWith();', 7 | 'expect(fn).toHaveBeenCalledWith();', 8 | 'expect(fn).toBeCalledWith(expect.anything());', 9 | 'expect(fn).toHaveBeenCalledWith(expect.anything());', 10 | 'expect(fn).not.toBeCalled();', 11 | 'expect(fn).rejects.not.toBeCalled();', 12 | 'expect(fn).not.toHaveBeenCalled();', 13 | 'expect(fn).not.toBeCalledWith();', 14 | 'expect(fn).not.toHaveBeenCalledWith();', 15 | 'expect(fn).resolves.not.toHaveBeenCalledWith();', 16 | 'expect(fn).toBeCalledTimes(0);', 17 | 'expect(fn).toHaveBeenCalledTimes(0);', 18 | 'expect(fn);' 19 | ], 20 | invalid: [ 21 | { 22 | code: 'expect(fn).toBeCalled();', 23 | errors: [ 24 | { 25 | messageId: 'preferCalledWith', 26 | data: { matcherName: 'toBeCalled' }, 27 | column: 12, 28 | line: 1 29 | } 30 | ], 31 | output: 'expect(fn).toBeCalledWith();' 32 | }, 33 | { 34 | code: 'expect(fn).resolves.toBeCalled();', 35 | errors: [ 36 | { 37 | messageId: 'preferCalledWith', 38 | data: { matcherName: 'toBeCalled' }, 39 | column: 21, 40 | line: 1 41 | } 42 | ], 43 | output: 'expect(fn).resolves.toBeCalledWith();' 44 | }, 45 | { 46 | code: 'expect(fn).toHaveBeenCalled();', 47 | errors: [ 48 | { 49 | messageId: 'preferCalledWith', 50 | data: { matcherName: 'toHaveBeenCalled' }, 51 | column: 12, 52 | line: 1 53 | } 54 | ], 55 | output: 'expect(fn).toHaveBeenCalledWith();' 56 | } 57 | ] 58 | }) 59 | -------------------------------------------------------------------------------- /src/rules/prefer-to-be-truthy.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule, getAccessorValue } from '../utils' 3 | import { getFirstMatcherArg, parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | import { EqualityMatcher } from '../utils/types' 5 | 6 | type MESSAGE_IDS = 'preferToBeTruthy' 7 | export const RULE_NAME = 'prefer-to-be-truthy' 8 | type Options = [] 9 | 10 | interface TrueLiteral extends TSESTree.BooleanLiteral { 11 | value: true; 12 | } 13 | 14 | const isTrueLiteral = (node: TSESTree.Node): node is TrueLiteral => 15 | node.type === AST_NODE_TYPES.Literal && node.value === true 16 | 17 | export default createEslintRule({ 18 | name: RULE_NAME, 19 | meta: { 20 | type: 'suggestion', 21 | docs: { 22 | description: 'Suggest using `toBeTruthy`', 23 | recommended: 'warn' 24 | }, 25 | messages: { 26 | preferToBeTruthy: 'Prefer using `toBeTruthy` to test value is `true`' 27 | }, 28 | fixable: 'code', 29 | schema: [] 30 | }, 31 | defaultOptions: [], 32 | create(context) { 33 | return { 34 | CallExpression(node) { 35 | const vitestFnCall = parseVitestFnCall(node, context) 36 | 37 | if (!(vitestFnCall?.type === 'expect' || vitestFnCall?.type === 'expectTypeOf')) return 38 | 39 | if (vitestFnCall.args.length === 1 && 40 | isTrueLiteral(getFirstMatcherArg(vitestFnCall)) && 41 | // eslint-disable-next-line no-prototype-builtins 42 | EqualityMatcher.hasOwnProperty(getAccessorValue(vitestFnCall.matcher))) { 43 | context.report({ 44 | node: vitestFnCall.matcher, 45 | messageId: 'preferToBeTruthy', 46 | fix: fixer => [ 47 | fixer.replaceText(vitestFnCall.matcher, 'toBeTruthy'), 48 | fixer.remove(vitestFnCall.args[0]) 49 | ] 50 | }) 51 | } 52 | } 53 | } 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /tests/prefer-lowercase-title.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/prefer-lowercase-title' 2 | import { TestCaseName } from '../src/utils/types' 3 | import { ruleTester } from './ruleTester' 4 | 5 | ruleTester.run(RULE_NAME, rule, { 6 | valid: [ 7 | 'it.each()', 8 | 'it.each()(1)', 9 | 'it.todo();', 10 | 'describe("oo", function () {})', 11 | 'test("foo", function () {})', 12 | 'test(`123`, function () {})' 13 | ], 14 | invalid: [ 15 | { 16 | code: 'it("Foo MM mm", function () {})', 17 | output: 'it("foo MM mm", function () {})', 18 | errors: [ 19 | { 20 | messageId: 'lowerCaseTitle', 21 | data: { 22 | method: TestCaseName.it 23 | } 24 | } 25 | ] 26 | }, 27 | { 28 | code: 'test(`Foo MM mm`, function () {})', 29 | output: 'test(`foo MM mm`, function () {})', 30 | errors: [ 31 | { 32 | messageId: 'lowerCaseTitle', 33 | data: { 34 | method: TestCaseName.test 35 | } 36 | } 37 | ] 38 | }, 39 | { 40 | code: 'test(`SFC Compile`, function () {})', 41 | output: 'test(`sfc compile`, function () {})', 42 | errors: [ 43 | { 44 | messageId: 'lowerCaseTitle', 45 | data: { 46 | method: TestCaseName.test 47 | } 48 | } 49 | ], 50 | options: [ 51 | { 52 | lowercaseFirstCharacterOnly: false 53 | } 54 | ] 55 | }, 56 | { 57 | code: 'bench(`Foo MM mm`, function () {})', 58 | output: 'bench(`foo MM mm`, function () {})', 59 | errors: [ 60 | { 61 | messageId: 'lowerCaseTitle', 62 | data: { 63 | method: TestCaseName.bench 64 | } 65 | } 66 | ] 67 | }, 68 | ] 69 | }) 70 | -------------------------------------------------------------------------------- /src/utils/msc.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' 2 | import { getFirstMatcherArg, ParsedExpectVitestFnCall } from './parseVitestFnCall' 3 | import { EqualityMatcher } from './types' 4 | import { getAccessorValue, isSupportedAccessor } from '.' 5 | 6 | export const isBooleanLiteral = (node: TSESTree.Node): node is TSESTree.BooleanLiteral => 7 | node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean' 8 | 9 | /** 10 | * Checks if the given `ParsedExpectMatcher` is either a call to one of the equality matchers, 11 | * with a boolean` literal as the sole argument, *or* is a call to `toBeTruthy` or `toBeFalsy`. 12 | */ 13 | export const isBooleanEqualityMatcher = ( 14 | expectFnCall: ParsedExpectVitestFnCall 15 | ): boolean => { 16 | const matcherName = getAccessorValue(expectFnCall.matcher) 17 | 18 | if (['toBeTruthy', 'toBeFalsy'].includes(matcherName)) 19 | return true 20 | 21 | if (expectFnCall.args.length !== 1) 22 | return false 23 | 24 | const arg = getFirstMatcherArg(expectFnCall) 25 | 26 | // eslint-disable-next-line no-prototype-builtins 27 | return EqualityMatcher.hasOwnProperty(matcherName) && isBooleanLiteral(arg) 28 | } 29 | 30 | export const isInstanceOfBinaryExpression = ( 31 | node: TSESTree.Node, 32 | className: string 33 | ): node is TSESTree.BinaryExpression => 34 | node.type === AST_NODE_TYPES.BinaryExpression && 35 | node.operator === 'instanceof' && 36 | isSupportedAccessor(node.right, className) 37 | 38 | export interface CallExpressionWithSingleArgument< 39 | Argument extends TSESTree.CallExpression['arguments'][number] = TSESTree.CallExpression['arguments'][number], 40 | > extends TSESTree.CallExpression { 41 | arguments: [Argument]; 42 | } 43 | 44 | export const hasOnlyOneArgument = ( 45 | call: TSESTree.CallExpression 46 | ): call is CallExpressionWithSingleArgument => call.arguments.length === 1 47 | -------------------------------------------------------------------------------- /src/rules/prefer-hooks-in-order.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule } from '../utils/index' 2 | import { isTypeOfVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'prefer-hooks-in-order' 5 | type MESSAGE_IDS = 'reorderHooks'; 6 | type Options = [] 7 | 8 | const HooksOrder = ['beforeAll', 'beforeEach', 'afterEach', 'afterAll'] 9 | 10 | export default createEslintRule({ 11 | name: RULE_NAME, 12 | meta: { 13 | type: 'suggestion', 14 | docs: { 15 | description: 'Prefer having hooks in consistent order', 16 | recommended: 'warn' 17 | }, 18 | messages: { 19 | reorderHooks: '`{{ currentHook }}` hooks should be before any `{{ previousHook }}` hooks' 20 | }, 21 | schema: [] 22 | }, 23 | defaultOptions: [], 24 | create(context) { 25 | let previousHookIndex = -1 26 | let inHook = false 27 | 28 | return { 29 | CallExpression(node) { 30 | if (inHook) return 31 | 32 | const vitestFnCall = parseVitestFnCall(node, context) 33 | 34 | if (vitestFnCall?.type !== 'hook') { 35 | previousHookIndex = -1 36 | return 37 | } 38 | 39 | inHook = true 40 | const currentHook = vitestFnCall.name 41 | const currentHookIndex = HooksOrder.indexOf(currentHook) 42 | 43 | if (currentHookIndex < previousHookIndex) { 44 | context.report({ 45 | messageId: 'reorderHooks', 46 | data: { 47 | previousHook: HooksOrder[previousHookIndex], 48 | currentHook 49 | }, 50 | node 51 | }) 52 | inHook = false 53 | return 54 | } 55 | 56 | previousHookIndex = currentHookIndex 57 | }, 58 | 'CallExpression:exit'(node) { 59 | if (isTypeOfVitestFnCall(node, context, ['hook'])) { 60 | inHook = false 61 | return 62 | } 63 | 64 | if (inHook) 65 | return 66 | 67 | previousHookIndex = -1 68 | } 69 | } 70 | } 71 | }) 72 | -------------------------------------------------------------------------------- /tests/no-alias-methods.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-alias-methods' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'expect(a).toHaveBeenCalled()', 7 | 'expect(a).toHaveBeenCalledTimes()', 8 | 'expect(a).toHaveBeenCalledWith()', 9 | 'expect(a).toHaveBeenLastCalledWith()', 10 | 'expect(a).toHaveBeenNthCalledWith()', 11 | 'expect(a).toHaveReturned()', 12 | 'expect(a).toHaveReturnedTimes()', 13 | 'expect(a).toHaveReturnedWith()', 14 | 'expect(a).toHaveLastReturnedWith()', 15 | 'expect(a).toHaveNthReturnedWith()', 16 | 'expect(a).toThrow()', 17 | 'expect(a).rejects;', 18 | 'expect(a);' 19 | ], 20 | invalid: [ 21 | { 22 | code: 'expect(a).toBeCalled()', 23 | output: 'expect(a).toHaveBeenCalled()', 24 | errors: [ 25 | { 26 | messageId: 'noAliasMethods', 27 | data: { 28 | alias: 'toBeCalled', 29 | canonical: 'toHaveBeenCalled' 30 | }, 31 | column: 11, 32 | line: 1 33 | } 34 | ] 35 | }, 36 | { 37 | code: 'expect(a).toBeCalledTimes()', 38 | output: 'expect(a).toHaveBeenCalledTimes()', 39 | errors: [ 40 | { 41 | messageId: 'noAliasMethods', 42 | data: { 43 | alias: 'toBeCalledTimes', 44 | canonical: 'toHaveBeenCalledTimes' 45 | }, 46 | column: 11, 47 | line: 1 48 | } 49 | ] 50 | }, 51 | { 52 | code: 'expect(a).not["toThrowError"]()', 53 | output: 'expect(a).not[\'toThrow\']()', 54 | errors: [ 55 | { 56 | messageId: 'noAliasMethods', 57 | data: { 58 | alias: 'toThrowError', 59 | canonical: 'toThrow' 60 | }, 61 | column: 15, 62 | line: 1 63 | } 64 | ] 65 | } 66 | ] 67 | }) 68 | -------------------------------------------------------------------------------- /src/rules/no-alias-methods.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule, getAccessorValue, replaceAccessorFixer } from '../utils' 2 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'no-alias-methods' 5 | export type MESSAGE_ID = 'noAliasMethods' 6 | export type Options = [] 7 | 8 | export default createEslintRule({ 9 | name: RULE_NAME, 10 | meta: { 11 | docs: { 12 | description: 'Disallow alias methods', 13 | requiresTypeChecking: false, 14 | recommended: 'error' 15 | }, 16 | messages: { 17 | noAliasMethods: 'Replace {{ alias }}() with its canonical name {{ canonical }}()' 18 | }, 19 | type: 'suggestion', 20 | fixable: 'code', 21 | schema: [] 22 | }, 23 | defaultOptions: [], 24 | create(context) { 25 | const methodNames: Record = { 26 | toBeCalled: 'toHaveBeenCalled', 27 | toBeCalledTimes: 'toHaveBeenCalledTimes', 28 | toBeCalledWith: 'toHaveBeenCalledWith', 29 | lastCalledWith: 'toHaveBeenLastCalledWith', 30 | nthCalledWith: 'toHaveBeenNthCalledWith', 31 | toReturn: 'toHaveReturned', 32 | toReturnTimes: 'toHaveReturnedTimes', 33 | toReturnWith: 'toHaveReturnedWith', 34 | lastReturnedWith: 'toHaveLastReturnedWith', 35 | nthReturnedWith: 'toHaveNthReturnedWith', 36 | toThrowError: 'toThrow' 37 | } 38 | 39 | return { 40 | CallExpression(node) { 41 | const vitestFnCall = parseVitestFnCall(node, context) 42 | 43 | if (vitestFnCall?.type !== 'expect') 44 | return 45 | 46 | const { matcher } = vitestFnCall 47 | 48 | const alias = getAccessorValue(matcher) 49 | 50 | if (alias in methodNames) { 51 | const canonical = methodNames[alias] 52 | 53 | context.report({ 54 | messageId: 'noAliasMethods', 55 | data: { alias, canonical }, 56 | node: matcher, 57 | fix: fixer => [replaceAccessorFixer(fixer, matcher, canonical)] 58 | }) 59 | } 60 | } 61 | } 62 | } 63 | }) 64 | -------------------------------------------------------------------------------- /src/rules/prefer-to-have-length.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils' 2 | import { createEslintRule, getAccessorValue, isSupportedAccessor } from '../utils' 3 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | import { EqualityMatcher } from '../utils/types' 5 | 6 | export type MESSAGE_IDS = 'preferToHaveLength'; 7 | export const RULE_NAME = 'prefer-to-have-length' 8 | type Options = [] 9 | 10 | export default createEslintRule({ 11 | name: RULE_NAME, 12 | meta: { 13 | type: 'suggestion', 14 | docs: { 15 | description: 'Suggest using toHaveLength()', 16 | recommended: false 17 | }, 18 | fixable: 'code', 19 | messages: { 20 | preferToHaveLength: 'Prefer toHaveLength()' 21 | }, 22 | schema: [] 23 | }, 24 | defaultOptions: [], 25 | create(context) { 26 | return { 27 | CallExpression(node) { 28 | const vitestFnCall = parseVitestFnCall(node, context) 29 | 30 | if (vitestFnCall?.type !== 'expect') 31 | return 32 | 33 | const { parent: expect } = vitestFnCall.head.node 34 | 35 | if (expect?.type !== AST_NODE_TYPES.CallExpression) 36 | return 37 | 38 | const [argument] = expect.arguments 39 | const { matcher } = vitestFnCall 40 | 41 | // eslint-disable-next-line no-prototype-builtins 42 | if (!EqualityMatcher.hasOwnProperty(getAccessorValue(matcher)) || 43 | argument?.type !== AST_NODE_TYPES.MemberExpression || 44 | !isSupportedAccessor(argument.property, 'length')) 45 | return 46 | 47 | context.report({ 48 | node: matcher, 49 | messageId: 'preferToHaveLength', 50 | fix(fixer) { 51 | return [ 52 | fixer.removeRange([ 53 | argument.property.range[0] - 1, 54 | argument.range[1] 55 | ]), 56 | 57 | fixer.replaceTextRange( 58 | [matcher.parent.object.range[1], matcher.parent.range[1]], 59 | '.toHaveLength' 60 | ) 61 | ] 62 | } 63 | }) 64 | } 65 | } 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /src/rules/max-nested-describe.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule } from '../utils' 3 | 4 | export const RULE_NAME = 'max-nested-describe' 5 | export type MESSAGE_ID = 'maxNestedDescribe'; 6 | export type Options = [ 7 | { 8 | max: number; 9 | } 10 | ]; 11 | 12 | export default createEslintRule({ 13 | name: RULE_NAME, 14 | meta: { 15 | type: 'problem', 16 | docs: { 17 | description: 18 | 'Nested describe block should be less than set max value or default value', 19 | recommended: 'error' 20 | }, 21 | schema: [ 22 | { 23 | type: 'object', 24 | properties: { 25 | max: { 26 | type: 'number' 27 | } 28 | }, 29 | additionalProperties: false 30 | } 31 | ], 32 | messages: { 33 | maxNestedDescribe: 34 | 'Nested describe block should be less than set max value.' 35 | } 36 | }, 37 | defaultOptions: [ 38 | { 39 | max: 5 40 | } 41 | ], 42 | create(context, [{ max }]) { 43 | const stack: number[] = [] 44 | 45 | function pushStack(node: TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression) { 46 | if (node.parent?.type !== 'CallExpression') return 47 | if (node.parent.callee.type !== 'Identifier' || node.parent.callee.name !== 'describe') 48 | return 49 | 50 | stack.push(0) 51 | 52 | if (stack.length > max) { 53 | context.report({ 54 | node: node.parent, 55 | messageId: 'maxNestedDescribe' 56 | }) 57 | } 58 | } 59 | function popStack(node: TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression) { 60 | if (node.parent?.type !== 'CallExpression') return 61 | if (node.parent.callee.type !== 'Identifier' || node.parent.callee.name !== 'describe') 62 | return 63 | 64 | stack.pop() 65 | } 66 | 67 | return { 68 | FunctionExpression: pushStack, 69 | 'FunctionExpression:exit': popStack, 70 | ArrowFunctionExpression: pushStack, 71 | 'ArrowFunctionExpression:exit': popStack 72 | } 73 | } 74 | }) 75 | -------------------------------------------------------------------------------- /src/rules/no-test-return-statement.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule, isFunction } from '../utils' 3 | import { getTestCallExpressionsFromDeclaredVariables, isTypeOfVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'no-test-return-statement' 6 | export type MessageIds = 'noTestReturnStatement'; 7 | type Options = [] 8 | 9 | const getBody = (args: TSESTree.CallExpressionArgument[]) => { 10 | const [, secondArg] = args 11 | 12 | if (secondArg && isFunction(secondArg) && secondArg.body.type === AST_NODE_TYPES.BlockStatement) 13 | return secondArg.body.body 14 | return [] 15 | } 16 | 17 | export default createEslintRule({ 18 | name: RULE_NAME, 19 | meta: { 20 | type: 'problem', 21 | docs: { 22 | description: 'Disallow return statements in tests', 23 | recommended: 'error' 24 | }, 25 | schema: [], 26 | messages: { 27 | noTestReturnStatement: 'Return statements are not allowed in tests' 28 | } 29 | }, 30 | defaultOptions: [], 31 | create(context) { 32 | return { 33 | CallExpression(node) { 34 | if (!isTypeOfVitestFnCall(node, context, ['test'])) 35 | return 36 | 37 | const body = getBody(node.arguments) 38 | const returnStmt = body.find(stmt => stmt.type === AST_NODE_TYPES.ReturnStatement) 39 | 40 | if (!returnStmt) return 41 | 42 | context.report({ messageId: 'noTestReturnStatement', node: returnStmt }) 43 | }, 44 | FunctionDeclaration(node) { 45 | const declaredVariables = context.getDeclaredVariables(node) 46 | const testCallExpressions = getTestCallExpressionsFromDeclaredVariables(declaredVariables, context) 47 | 48 | if (testCallExpressions.length === 0) return 49 | 50 | const returnStmt = node.body.body.find(stmt => stmt.type === AST_NODE_TYPES.ReturnStatement) 51 | 52 | if (!returnStmt) return 53 | 54 | context.report({ messageId: 'noTestReturnStatement', node: returnStmt }) 55 | } 56 | } 57 | } 58 | }) 59 | -------------------------------------------------------------------------------- /src/rules/no-focused-tests.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule } from '../utils' 3 | 4 | export type MessageIds = 'noFocusedTests'; 5 | export const RULE_NAME = 'no-focused-tests' 6 | export type Options = []; 7 | 8 | const isTestOrDescribe = (node: TSESTree.Expression) => { 9 | return node.type === 'Identifier' && ['it', 'test', 'describe'].includes(node.name) 10 | } 11 | 12 | const isOnly = (node: TSESTree.Expression|TSESTree.PrivateIdentifier) => { 13 | return node.type === 'Identifier' && node.name === 'only' 14 | } 15 | 16 | export default createEslintRule({ 17 | name: RULE_NAME, 18 | meta: { 19 | type: 'problem', 20 | docs: { 21 | description: 'Disallow focused tests', 22 | recommended: 'error' 23 | }, 24 | fixable: 'code', 25 | schema: [], 26 | messages: { 27 | noFocusedTests: 'Focused tests are not allowed.' 28 | } 29 | }, 30 | defaultOptions: [], 31 | create: (context) => { 32 | return { 33 | ExpressionStatement(node) { 34 | if (node.expression.type === 'CallExpression') { 35 | const { callee } = node.expression 36 | if ( 37 | callee.type === 'MemberExpression' && 38 | isTestOrDescribe(callee.object) && 39 | isOnly(callee.property) 40 | ) { 41 | context.report({ 42 | node: callee.property, 43 | messageId: 'noFocusedTests' 44 | }) 45 | } 46 | } 47 | }, 48 | CallExpression(node) { 49 | if (node.callee.type === 'CallExpression') { 50 | const { callee } = node.callee 51 | 52 | if ( 53 | callee.type === 'MemberExpression' && 54 | callee.object.type === 'MemberExpression' && 55 | isTestOrDescribe(callee.object.object) && 56 | isOnly(callee.object.property) && 57 | callee.property.type === 'Identifier' && 58 | callee.property.name === 'each' 59 | ) { 60 | context.report({ 61 | node: callee.object.property, 62 | messageId: 'noFocusedTests' 63 | }) 64 | } 65 | } 66 | } 67 | } 68 | } 69 | }) 70 | -------------------------------------------------------------------------------- /src/rules/consistent-test-filename.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import { createEslintRule } from '../utils' 3 | 4 | export const RULE_NAME = 'consistent-test-filename' 5 | 6 | const defaultPattern = /.*\.test\.[tj]sx?$/ 7 | const defaultTestsPattern = /.*\.(test|spec)\.[tj]sx?$/ 8 | 9 | export default createEslintRule< 10 | [ 11 | Partial<{ 12 | pattern: string; 13 | allTestPattern: string; 14 | }> 15 | ], 16 | 'consistentTestFilename' 17 | >({ 18 | name: RULE_NAME, 19 | meta: { 20 | type: 'problem', 21 | docs: { 22 | recommended: 'error', 23 | requiresTypeChecking: false, 24 | description: 'forbidden .spec test file pattern' 25 | }, 26 | messages: { 27 | consistentTestFilename: 'use test file name pattern {{pattern}}' 28 | }, 29 | schema: [ 30 | { 31 | type: 'object', 32 | additionalProperties: false, 33 | properties: { 34 | pattern: { 35 | format: 'regex', 36 | default: defaultPattern.source 37 | }, 38 | allTestPattern: { 39 | format: 'regex', 40 | default: defaultTestsPattern.source 41 | } 42 | } 43 | } 44 | ] 45 | }, 46 | defaultOptions: [{ pattern: defaultTestsPattern.source, allTestPattern: defaultTestsPattern.source }], 47 | 48 | create: (context) => { 49 | const config = context.options[0] ?? {} 50 | const { pattern: patternRaw = defaultPattern, allTestPattern: allTestPatternRaw = defaultTestsPattern } = config 51 | const pattern = typeof patternRaw === 'string' ? new RegExp(patternRaw) : patternRaw 52 | const testPattern = typeof allTestPatternRaw === 'string' ? new RegExp(allTestPatternRaw) : allTestPatternRaw 53 | 54 | const filename = path.basename(context.getFilename()) 55 | if (!testPattern.test(filename)) 56 | return {} 57 | 58 | return { 59 | Program: (p) => { 60 | if (!pattern.test(filename)) { 61 | context.report({ 62 | node: p, 63 | messageId: 'consistentTestFilename', 64 | data: { 65 | pattern: pattern.source 66 | } 67 | }) 68 | } 69 | } 70 | } 71 | } 72 | }) 73 | -------------------------------------------------------------------------------- /src/rules/max-expect.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils' 2 | import { createEslintRule, FunctionExpression } from '../utils' 3 | import { isTypeOfVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'max-expect' 6 | export type MESSAGE_ID = 'maxExpect'; 7 | type Options = [ 8 | { 9 | max: number 10 | } 11 | ] 12 | 13 | export default createEslintRule({ 14 | name: RULE_NAME, 15 | meta: { 16 | docs: { 17 | requiresTypeChecking: false, 18 | recommended: 'error', 19 | description: 'Enforce a maximum number of expect per test' 20 | }, 21 | messages: { 22 | maxExpect: 'Too many assertion calls ({{count}}). Maximum is {{max}}.' 23 | }, 24 | type: 'suggestion', 25 | schema: [ 26 | { 27 | type: 'object', 28 | properties: { 29 | max: { 30 | type: 'number' 31 | } 32 | }, 33 | additionalProperties: false 34 | } 35 | ] 36 | }, 37 | defaultOptions: [{ max: 5 }], 38 | create(context, [{ max }]) { 39 | let assertsCount = 0 40 | 41 | const resetAssertCount = (node: FunctionExpression) => { 42 | const isFunctionTest = node.parent?.type !== AST_NODE_TYPES.CallExpression || 43 | isTypeOfVitestFnCall(node.parent, context, ['test']) 44 | 45 | if (isFunctionTest) 46 | assertsCount = 0 47 | } 48 | 49 | return { 50 | FunctionExpression: resetAssertCount, 51 | 'FunctionExpression:exit': resetAssertCount, 52 | ArrowFunctionExpression: resetAssertCount, 53 | 'ArrowFunctionExpression:exit': resetAssertCount, 54 | CallExpression(node) { 55 | const vitestFnCall = parseVitestFnCall(node, context) 56 | 57 | if (vitestFnCall?.type !== 'expect' || vitestFnCall.head.node.parent?.type === AST_NODE_TYPES.MemberExpression) 58 | return 59 | 60 | assertsCount += 1 61 | 62 | if (assertsCount > max) { 63 | context.report({ 64 | node, 65 | messageId: 'maxExpect', 66 | data: { 67 | count: assertsCount, 68 | max 69 | } 70 | }) 71 | } 72 | } 73 | } 74 | } 75 | }) 76 | -------------------------------------------------------------------------------- /tests/max-nested-describe.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/max-nested-describe' 2 | import { ruleTester } from './ruleTester' 3 | 4 | const valid = [ 5 | `describe('another suite', () => { 6 | describe('another suite', () => { 7 | it('skipped test', () => { 8 | // Test skipped, as tests are running in Only mode 9 | assert.equal(Math.sqrt(4), 3) 10 | }) 11 | 12 | it.only('test', () => { 13 | // Only this test (and others marked with only) are run 14 | assert.equal(Math.sqrt(4), 2) 15 | }) 16 | }) 17 | })`, 18 | `describe('another suite', () => { 19 | describe('another suite', () => { 20 | describe('another suite', () => { 21 | describe('another suite', () => { 22 | 23 | }) 24 | }) 25 | }) 26 | })` 27 | ] 28 | 29 | const invalid = [ 30 | `describe('another suite', () => { 31 | describe('another suite', () => { 32 | describe('another suite', () => { 33 | describe('another suite', () => { 34 | describe('another suite', () => { 35 | describe('another suite', () => { 36 | 37 | }) 38 | }) 39 | }) 40 | }) 41 | }) 42 | })`, 43 | `describe('another suite', () => { 44 | describe('another suite', () => { 45 | describe('another suite', () => { 46 | describe('another suite', () => { 47 | describe('another suite', () => { 48 | describe('another suite', () => { 49 | it('skipped test', () => { 50 | // Test skipped, as tests are running in Only mode 51 | assert.equal(Math.sqrt(4), 3) 52 | }) 53 | 54 | it.only('test', () => { 55 | // Only this test (and others marked with only) are run 56 | assert.equal(Math.sqrt(4), 2) 57 | }) 58 | }) 59 | }) 60 | }) 61 | }) 62 | }) 63 | }) ` 64 | ] 65 | 66 | ruleTester.run(RULE_NAME, rule, { 67 | valid, 68 | invalid: invalid.map((i) => ({ 69 | code: i, 70 | output: i, 71 | errors: [{ messageId: 'maxNestedDescribe' }] 72 | })) 73 | }) 74 | -------------------------------------------------------------------------------- /tests/no-hooks.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules//no-hooks' 2 | import { HookName } from '../src/utils/types' 3 | import { ruleTester } from './ruleTester' 4 | 5 | ruleTester.run(RULE_NAME, rule, { 6 | valid: [ 7 | 'test("foo")', 8 | 'describe("foo", () => { it("bar") })', 9 | 'test("foo", () => { expect(subject.beforeEach()).toBe(true) })', 10 | { 11 | code: 'afterEach(() => {}); afterAll(() => {});', 12 | options: [{ allow: [HookName.afterEach, HookName.afterAll] }] 13 | }, 14 | { code: 'test("foo")', options: [{ allow: undefined }] } 15 | ], 16 | invalid: [ 17 | { 18 | code: 'beforeAll(() => {})', 19 | errors: [ 20 | { 21 | messageId: 'unexpectedHook', 22 | data: { hookName: HookName.beforeAll } 23 | } 24 | ] 25 | }, 26 | { 27 | code: 'beforeEach(() => {})', 28 | errors: [ 29 | { 30 | messageId: 'unexpectedHook', 31 | data: { hookName: HookName.beforeEach } 32 | } 33 | ] 34 | }, 35 | { 36 | code: 'afterAll(() => {})', 37 | errors: [ 38 | { 39 | messageId: 'unexpectedHook', 40 | data: { hookName: HookName.afterAll } 41 | } 42 | ] 43 | }, 44 | { 45 | code: 'afterEach(() => {})', 46 | errors: [ 47 | { 48 | messageId: 'unexpectedHook', 49 | data: { hookName: HookName.afterEach } 50 | } 51 | ] 52 | }, 53 | { 54 | code: 'beforeEach(() => {}); afterEach(() => { vi.resetModules() });', 55 | options: [{ allow: [HookName.afterEach] }], 56 | errors: [ 57 | { 58 | messageId: 'unexpectedHook', 59 | data: { hookName: HookName.beforeEach } 60 | } 61 | ] 62 | }, 63 | { 64 | code: ` 65 | import { beforeEach as afterEach, afterEach as beforeEach, vi } from 'vitest'; 66 | afterEach(() => {}); 67 | beforeEach(() => { vi.resetModules() }); 68 | `, 69 | options: [{ allow: [HookName.afterEach] }], 70 | parserOptions: { sourceType: 'module' }, 71 | errors: [ 72 | { 73 | messageId: 'unexpectedHook', 74 | data: { hookName: HookName.beforeEach } 75 | } 76 | ] 77 | } 78 | ] 79 | }) 80 | -------------------------------------------------------------------------------- /src/rules/max-expects.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils' 2 | import { createEslintRule, FunctionExpression } from '../utils' 3 | import { isTypeOfVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'max-expects' 6 | export type MESSAGE_ID = 'maxExpect'; 7 | export type Options = [ 8 | { 9 | max: number 10 | } 11 | ] 12 | 13 | export default createEslintRule({ 14 | name: RULE_NAME, 15 | meta: { 16 | docs: { 17 | requiresTypeChecking: false, 18 | recommended: 'error', 19 | description: 'Enforce a maximum number of expect per test' 20 | }, 21 | messages: { 22 | maxExpect: 'Too many assertion calls ({{count}}). Maximum is {{max}}.' 23 | }, 24 | type: 'suggestion', 25 | schema: [ 26 | { 27 | type: 'object', 28 | properties: { 29 | max: { 30 | type: 'number' 31 | } 32 | }, 33 | additionalProperties: false 34 | } 35 | ] 36 | }, 37 | defaultOptions: [{ max: 5 }], 38 | create(context, [{ max }]) { 39 | let assertsCount = 0 40 | 41 | const resetAssertCount = (node: FunctionExpression) => { 42 | const isFunctionTest = node.parent?.type !== AST_NODE_TYPES.CallExpression || 43 | isTypeOfVitestFnCall(node.parent, context, ['test']) 44 | 45 | if (isFunctionTest) 46 | assertsCount = 0 47 | } 48 | 49 | return { 50 | FunctionExpression: resetAssertCount, 51 | 'FunctionExpression:exit': resetAssertCount, 52 | ArrowFunctionExpression: resetAssertCount, 53 | 'ArrowFunctionExpression:exit': resetAssertCount, 54 | CallExpression(node) { 55 | const vitestFnCall = parseVitestFnCall(node, context) 56 | 57 | if (vitestFnCall?.type !== 'expect' || vitestFnCall.head.node.parent?.type === AST_NODE_TYPES.MemberExpression) 58 | return 59 | 60 | assertsCount += 1 61 | 62 | if (assertsCount > max) { 63 | context.report({ 64 | node, 65 | messageId: 'maxExpect', 66 | data: { 67 | count: assertsCount, 68 | max 69 | } 70 | }) 71 | } 72 | } 73 | } 74 | } 75 | }) 76 | -------------------------------------------------------------------------------- /tests/no-restricted-vi-methods.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-restricted-vi-methods' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'vi', 7 | 'vi()', 8 | 'vi.mock()', 9 | 'expect(a).rejects;', 10 | 'expect(a);', 11 | { 12 | code: ` 13 | import { vi } from 'vitest'; 14 | vi; 15 | `, 16 | parserOptions: { sourceType: 'module' } 17 | } 18 | ], 19 | 20 | invalid: [ 21 | { 22 | code: 'vi.fn()', 23 | options: [{ fn: null }], 24 | errors: [ 25 | { 26 | messageId: 'restrictedViMethod', 27 | data: { 28 | message: null, 29 | restriction: 'fn' 30 | }, 31 | column: 4, 32 | line: 1 33 | } 34 | ] 35 | }, 36 | { 37 | code: 'vi.mock()', 38 | options: [{ mock: 'Do not use mocks' }], 39 | errors: [ 40 | { 41 | messageId: 'restrictedViMethodWithMessage', 42 | data: { 43 | message: 'Do not use mocks', 44 | restriction: 'mock' 45 | }, 46 | column: 4, 47 | line: 1 48 | } 49 | ] 50 | }, 51 | { 52 | code: ` 53 | import { vi } from 'vitest'; 54 | vi.advanceTimersByTime(); 55 | `, 56 | options: [{ advanceTimersByTime: null }], 57 | parserOptions: { sourceType: 'module' }, 58 | errors: [ 59 | { 60 | messageId: 'restrictedViMethod', 61 | data: { 62 | message: null, 63 | restriction: 'advanceTimersByTime' 64 | }, 65 | column: 9, 66 | line: 3 67 | } 68 | ] 69 | }, 70 | { 71 | code: 'vi["fn"]()', 72 | options: [{ fn: null }], 73 | errors: [ 74 | { 75 | messageId: 'restrictedViMethod', 76 | data: { 77 | message: null, 78 | restriction: 'fn' 79 | }, 80 | column: 4, 81 | line: 1 82 | } 83 | ] 84 | } 85 | ] 86 | }) 87 | -------------------------------------------------------------------------------- /src/rules/no-restricted-matchers.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule, getAccessorValue } from '../utils' 2 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | import { ModifierName } from '../utils/types' 4 | 5 | export const RULE_NAME = 'no-restricted-matchers' 6 | type MESSAGE_IDS = 'restrictedChain' | 'restrictedChainWithMessage'; 7 | type Options = Record[] 8 | 9 | const isChainRestricted = (chain: string, restriction: string): boolean => { 10 | // eslint-disable-next-line no-prototype-builtins 11 | if (ModifierName.hasOwnProperty(restriction) || restriction.endsWith('.not')) 12 | return chain.startsWith(restriction) 13 | 14 | return chain === restriction 15 | } 16 | 17 | export default createEslintRule({ 18 | name: RULE_NAME, 19 | meta: { 20 | docs: { 21 | description: 'Disallow the use of certain matchers', 22 | recommended: 'error' 23 | }, 24 | type: 'suggestion', 25 | schema: [ 26 | { 27 | type: 'object', 28 | additionalProperties: { 29 | type: ['string', 'null'] 30 | } 31 | } 32 | ], 33 | messages: { 34 | restrictedChain: 'use of `{{ matcher }}` is disallowed', 35 | restrictedChainWithMessage: '{{ message }}' 36 | } 37 | }, 38 | defaultOptions: [{}], 39 | create(context, [restrictedChains]) { 40 | return { 41 | CallExpression(node) { 42 | const vitestFnCall = parseVitestFnCall(node, context) 43 | 44 | if (vitestFnCall?.type !== 'expect') 45 | return 46 | 47 | const chain = vitestFnCall.members 48 | .map(node => getAccessorValue(node)) 49 | .join('.') 50 | 51 | for (const [restriction, message] of Object.entries(restrictedChains)) { 52 | if (isChainRestricted(chain, restriction)) { 53 | context.report({ 54 | messageId: message ? 'restrictedChainWithMessage' : 'restrictedChain', 55 | data: { message, restriction }, 56 | loc: { 57 | start: vitestFnCall.members[0].loc.start, 58 | end: vitestFnCall.members[vitestFnCall.members.length - 1].loc.end 59 | } 60 | }) 61 | break 62 | } 63 | } 64 | } 65 | } 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /tests/no-commented-out-tests.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-commented-out-tests' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | '// foo("bar", function () {})', 7 | 'describe("foo", function () {})', 8 | 'it("foo", function () {})', 9 | 'describe.only("foo", function () {})', 10 | 'it.only("foo", function () {})', 11 | 'it.concurrent("foo", function () {})', 12 | 'test("foo", function () {})', 13 | 'test.only("foo", function () {})', 14 | 'test.concurrent("foo", function () {})', 15 | 'var appliedSkip = describe.skip; appliedSkip.apply(describe)', 16 | 'var calledSkip = it.skip; calledSkip.call(it)', 17 | '({ f: function () {} }).f()', 18 | '(a || b).f()', 19 | 'itHappensToStartWithIt()', 20 | 'testSomething()', 21 | '// latest(dates)', 22 | '// TODO: unify with Git implementation from Shipit (?)', 23 | '#!/usr/bin/env node' 24 | ], 25 | invalid: [ 26 | { 27 | code: '// describe(\'foo\', function () {})\'', 28 | errors: [ 29 | { 30 | messageId: 'noCommentedOutTests' 31 | } 32 | ] 33 | }, 34 | { 35 | code: '// test.concurrent("foo", function () {})', 36 | errors: [{ messageId: 'noCommentedOutTests', column: 1, line: 1 }] 37 | }, 38 | { 39 | code: '// test["skip"]("foo", function () {})', 40 | errors: [{ messageId: 'noCommentedOutTests', column: 1, line: 1 }] 41 | }, 42 | { 43 | code: '// xdescribe("foo", function () {})', 44 | errors: [{ messageId: 'noCommentedOutTests', column: 1, line: 1 }] 45 | }, 46 | { 47 | code: '// xit("foo", function () {})', 48 | errors: [{ messageId: 'noCommentedOutTests', column: 1, line: 1 }] 49 | }, 50 | { 51 | code: '// fit("foo", function () {})', 52 | errors: [{ messageId: 'noCommentedOutTests', column: 1, line: 1 }] 53 | }, 54 | { 55 | code: ` // test( 56 | // "foo", function () {} 57 | // )`, 58 | errors: [{ messageId: 'noCommentedOutTests', column: 2, line: 1 }] 59 | } 60 | ] 61 | }) 62 | -------------------------------------------------------------------------------- /docs/rules/require-hook.md: -------------------------------------------------------------------------------- 1 | # Require setup and teardown to be within a hook (`vitest/require-hook`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | It's common when writing tests to need to perform a particular setup work before and after a test suite run. Because Vitest executes all `describe` handlers in a test file _before_ it executes any of the actual tests, it's important to ensure setup and teardown work is done inside `before*` and `after*` handlers respectively, rather than inside the `describe` blocks. 8 | 9 | ## Details 10 | 11 | This rule flags any expression that is either at the toplevel of a test file or directly within the body of a `describe` expect the following. 12 | 13 | - `import` statements 14 | - `const` variables 15 | - `let` _declarations_ and initializations to `null` or `undefined` 16 | - Classes 17 | - Types 18 | 19 | This rule flags any function within in a `describe` block and suggest wrapping them in one of the four lifecycle hooks. 20 | 21 | 22 | The following patterns are considered warnings: 23 | 24 | ```ts 25 | import { database } from './api' 26 | 27 | describe('foo', () => { 28 | database.connect() 29 | 30 | test('bar', () => { 31 | // ... 32 | }) 33 | 34 | database.disconnect() 35 | }) 36 | ``` 37 | 38 | 39 | 40 | The following patterns are not warnings: 41 | 42 | 43 | 44 | ```ts 45 | describe('foo', () => { 46 | before(() => { 47 | database.connect() 48 | }) 49 | 50 | test('bar', () => { 51 | // ... 52 | }) 53 | }) 54 | ``` 55 | 56 | 57 | ## Options 58 | 59 | If there are methods that you want to call outside of hooks and tests, you can mark them as allowed using the `allowedFunctionCalls` option. 60 | 61 | 62 | ```json 63 | { 64 | "vitest/require-hook": ["error", { 65 | "allowedFunctionCalls": ["database.connect"] 66 | }] 67 | } 68 | ``` 69 | 70 | The following patterns are not warnings because `database.connect` is allowed: 71 | 72 | ```ts 73 | import { database } from './api' 74 | 75 | describe('foo', () => { 76 | database.connect() 77 | 78 | test('bar', () => { 79 | // ... 80 | }) 81 | 82 | database.disconnect() 83 | }) 84 | ``` 85 | -------------------------------------------------------------------------------- /tests/prefer-to-be-falsy.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/prefer-to-be-falsy' 2 | import { ruleTester } from './ruleTester' 3 | 4 | const messageId = 'preferToBeFalsy' 5 | 6 | ruleTester.run(RULE_NAME, rule, { 7 | valid: [ 8 | '[].push(false)', 9 | 'expect("something");', 10 | 'expect(true).toBeTrue();', 11 | 'expect(false).toBeTrue();', 12 | 'expect(false).toBeFalsy();', 13 | 'expect(true).toBeFalsy();', 14 | 'expect(value).toEqual();', 15 | 'expect(value).not.toBeFalsy();', 16 | 'expect(value).not.toEqual();', 17 | 'expect(value).toBe(undefined);', 18 | 'expect(value).not.toBe(undefined);', 19 | 'expect(false).toBe(true)', 20 | 'expect(value).toBe();', 21 | 'expect(true).toMatchSnapshot();', 22 | 'expect("a string").toMatchSnapshot(false);', 23 | 'expect("a string").not.toMatchSnapshot();', 24 | 'expect(something).toEqual(\'a string\');', 25 | 'expect(false).toBe', 26 | 'expectTypeOf(false).toBe' 27 | ], 28 | invalid: [ 29 | { 30 | code: 'expect(true).toBe(false);', 31 | output: 'expect(true).toBeFalsy();', 32 | errors: [{ messageId, column: 14, line: 1 }] 33 | }, 34 | { 35 | code: 'expect(wasSuccessful).toEqual(false);', 36 | output: 'expect(wasSuccessful).toBeFalsy();', 37 | errors: [{ messageId, column: 23, line: 1 }] 38 | }, 39 | { 40 | code: 'expect(fs.existsSync(\'/path/to/file\')).toStrictEqual(false);', 41 | output: 'expect(fs.existsSync(\'/path/to/file\')).toBeFalsy();', 42 | errors: [{ messageId, column: 40, line: 1 }] 43 | }, 44 | { 45 | code: 'expect("a string").not.toBe(false);', 46 | output: 'expect("a string").not.toBeFalsy();', 47 | errors: [{ messageId, column: 24, line: 1 }] 48 | }, 49 | { 50 | code: 'expect("a string").not.toEqual(false);', 51 | output: 'expect("a string").not.toBeFalsy();', 52 | errors: [{ messageId, column: 24, line: 1 }] 53 | }, 54 | { 55 | code: 'expectTypeOf("a string").not.toEqual(false);', 56 | output: 'expectTypeOf("a string").not.toBeFalsy();', 57 | errors: [{ messageId, column: 30, line: 1 }] 58 | } 59 | ] 60 | }) 61 | -------------------------------------------------------------------------------- /src/rules/prefer-each.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule } from '../utils' 3 | import { parseVitestFnCall, VitestFnType } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'prefer-each' 6 | export type MESSAGE_IDS = 'preferEach'; 7 | 8 | export default createEslintRule({ 9 | name: RULE_NAME, 10 | meta: { 11 | type: 'suggestion', 12 | docs: { 13 | description: 'Prefer `each` rather than manual loops', 14 | recommended: 'error' 15 | }, 16 | schema: [], 17 | messages: { 18 | preferEach: 'Prefer using `{{ fn }}.each` rather than a manual loop' 19 | } 20 | }, 21 | defaultOptions: [], 22 | create(context) { 23 | const vitestFnCalls: VitestFnType[] = [] 24 | let inTestCaseCall = false 25 | 26 | const recommendFn = () => { 27 | if (vitestFnCalls.length === 1 && vitestFnCalls[0] === 'test') 28 | return 'it' 29 | 30 | return 'describe' 31 | } 32 | 33 | const enterForLoop = () => { 34 | if (vitestFnCalls.length === 0 || inTestCaseCall) return 35 | 36 | vitestFnCalls.length = 0 37 | } 38 | 39 | const exitForLoop = ( 40 | node: 41 | | TSESTree.ForInStatement 42 | | TSESTree.ForOfStatement 43 | | TSESTree.ForStatement 44 | ) => { 45 | if (vitestFnCalls.length === 0 || inTestCaseCall) return 46 | 47 | context.report({ 48 | node, 49 | messageId: 'preferEach', 50 | data: { fn: recommendFn() } 51 | }) 52 | vitestFnCalls.length = 0 53 | } 54 | 55 | return { 56 | ForStatement: enterForLoop, 57 | 'ForStatement:exit': exitForLoop, 58 | ForInStatement: enterForLoop, 59 | 'ForInStatement:exit': exitForLoop, 60 | ForOfStatement: enterForLoop, 61 | 'ForOfStatement:exit': exitForLoop, 62 | CallExpression(node) { 63 | const { type: vitestFnCallType } = parseVitestFnCall(node, context) ?? {} 64 | 65 | if (vitestFnCallType === 'hook' || 66 | vitestFnCallType === 'describe' || 67 | vitestFnCallType === 'test') 68 | vitestFnCalls.push(vitestFnCallType) 69 | 70 | if (vitestFnCallType === 'test') 71 | inTestCaseCall = true 72 | }, 73 | 'CallExpression:exit'(node) { 74 | const { type: vitestFnCallType } = parseVitestFnCall(node, context) ?? {} 75 | if (vitestFnCallType === 'test') 76 | inTestCaseCall = false 77 | } 78 | } 79 | } 80 | }) 81 | -------------------------------------------------------------------------------- /tests/prefer-to-be-truthy.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/prefer-to-be-truthy' 2 | import { ruleTester } from './ruleTester' 3 | 4 | const messageId = 'preferToBeTruthy' 5 | 6 | ruleTester.run(RULE_NAME, rule, { 7 | valid: [ 8 | '[].push(true)', 9 | 'expect("something");', 10 | 'expect(true).toBeTrue();', 11 | 'expect(false).toBeTrue();', 12 | 'expect(fal,se).toBeFalse();', 13 | 'expect(true).toBeFalse();', 14 | 'expect(value).toEqual();', 15 | 'expect(value).not.toBeTrue();', 16 | 'expect(value).not.toEqual();', 17 | 'expect(value).toBe(undefined);', 18 | 'expect(value).not.toBe(undefined);', 19 | 'expect(true).toBe(false)', 20 | 'expect(value).toBe();', 21 | 'expect(true).toMatchSnapshot();', 22 | 'expect("a string").toMatchSnapshot(true);', 23 | 'expect("a string").not.toMatchSnapshot();', 24 | 'expect(something).toEqual(\'a string\');', 25 | 'expect(true).toBe', 26 | 'expectTypeOf(true).toBe()' 27 | ], 28 | invalid: [ 29 | { 30 | code: 'expect(false).toBe(true);', 31 | output: 'expect(false).toBeTruthy();', 32 | errors: [{ messageId, column: 15, line: 1 }] 33 | }, 34 | { 35 | code: 'expectTypeOf(false).toBe(true);', 36 | output: 'expectTypeOf(false).toBeTruthy();', 37 | errors: [{ messageId, column: 21, line: 1 }] 38 | }, 39 | { 40 | code: 'expect(wasSuccessful).toEqual(true);', 41 | output: 'expect(wasSuccessful).toBeTruthy();', 42 | errors: [{ messageId, column: 23, line: 1 }] 43 | }, 44 | { 45 | code: 'expect(fs.existsSync(\'/path/to/file\')).toStrictEqual(true);', 46 | output: 'expect(fs.existsSync(\'/path/to/file\')).toBeTruthy();', 47 | errors: [{ messageId, column: 40, line: 1 }] 48 | }, 49 | { 50 | code: 'expect("a string").not.toBe(true);', 51 | output: 'expect("a string").not.toBeTruthy();', 52 | errors: [{ messageId, column: 24, line: 1 }] 53 | }, 54 | { 55 | code: 'expect("a string").not.toEqual(true);', 56 | output: 'expect("a string").not.toBeTruthy();', 57 | errors: [{ messageId, column: 24, line: 1 }] 58 | }, 59 | { 60 | code: 'expectTypeOf("a string").not.toStrictEqual(true);', 61 | output: 'expectTypeOf("a string").not.toBeTruthy();', 62 | errors: [{ messageId, column: 30, line: 1 }] 63 | } 64 | ] 65 | }) 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-vitest", 3 | "version": "0.3.2", 4 | "license": "MIT", 5 | "description": "Eslint plugin for vitest", 6 | "repository": "veritem/eslint-plugin-vitest", 7 | "keywords": [ 8 | "eslint", 9 | "eslintplugin", 10 | "eslint-plugin", 11 | "vitest eslint plugin", 12 | "vitest", 13 | "eslint plugin" 14 | ], 15 | "author": "Verite Mugabo Makuza ", 16 | "main": "./dist/index.cjs", 17 | "module": "./dist/index.mjs", 18 | "types": "./dist/index.d.ts", 19 | "exports": { 20 | ".": { 21 | "require": "./dist/index.cjs", 22 | "default": "./dist/index.mjs", 23 | "types": "./dist/index.d.ts" 24 | } 25 | }, 26 | "files": [ 27 | "dist" 28 | ], 29 | "scripts": { 30 | "build": "unbuild", 31 | "lint:eslint-docs": "pnpm build && eslint-doc-generator --check", 32 | "lint:js": "eslint . --fix", 33 | "lint": "concurrently --prefixColors auto \"pnpm:lint:*\"", 34 | "release": "pnpm build && bumpp package.json --commit --push --tag && pnpm publish", 35 | "stub": "unbuild --stub", 36 | "test:ci": "vitest run", 37 | "test": "vitest", 38 | "generate": "tsx scripts/generate.ts", 39 | "update:eslint-docs": "pnpm build && eslint-doc-generator", 40 | "tsc": "tsc --noEmit" 41 | }, 42 | "devDependencies": { 43 | "@babel/types": "^7.23.0", 44 | "@types/mocha": "^10.0.2", 45 | "@types/node": "^20.8.6", 46 | "@typescript-eslint/eslint-plugin": "^6.7.5", 47 | "@typescript-eslint/rule-tester": "^6.7.5", 48 | "@veritem/eslint-config": "^0.0.11", 49 | "bumpp": "^9.2.0", 50 | "concurrently": "^8.2.1", 51 | "eslint": "^8.51.0", 52 | "eslint-doc-generator": "^1.5.2", 53 | "eslint-plugin-eslint-plugin": "^5.1.1", 54 | "eslint-plugin-node": "^11.1.0", 55 | "eslint-plugin-vitest": "^0.3.2", 56 | "eslint-remote-tester": "^3.0.1", 57 | "eslint-remote-tester-repositories": "^1.0.1", 58 | "ts-node": "^10.9.1", 59 | "tsx": "^3.13.0", 60 | "typescript": "^5.2.2", 61 | "unbuild": "^2.0.0", 62 | "vitest": "^0.34.6" 63 | }, 64 | "engines": { 65 | "node": "14.x || >= 16" 66 | }, 67 | "peerDependencies": { 68 | "eslint": ">=8.0.0", 69 | "vitest": "*" 70 | }, 71 | "peerDependenciesMeta": { 72 | "@typescript-eslint/eslint-plugin": { 73 | "optional": true 74 | }, 75 | "vitest": { 76 | "vitest": "*" 77 | } 78 | }, 79 | "dependencies": { 80 | "@typescript-eslint/utils": "^6.7.5" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/rules/unbound-method.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule, getAccessorValue } from '../utils' 3 | import { findTopMostCallExpression, parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'unbound-method' 6 | 7 | const toThrowMatchers = [ 8 | 'toThrow', 9 | 'toThrowError', 10 | 'toThrowErrorMatchingSnapshot', 11 | 'toThrowErrorMatchingInlineSnapshot' 12 | ] 13 | 14 | type MESSAGE_IDS = 'unbound' | 'unboundWithoutThisAnnotation' 15 | 16 | const DEFAULT_MESSAGE = 'This rule requires `@typescript-eslint/eslint-plugin`' 17 | 18 | interface Config { 19 | ignoreStatic: boolean; 20 | } 21 | 22 | export type Options = [Config]; 23 | 24 | const baseRule = (() => { 25 | try { 26 | // eslint-disable-next-line @typescript-eslint/no-var-requires 27 | const TSESLintPlugin = require('@typescript-eslint/eslint-plugin') 28 | 29 | return TSESLintPlugin.rules['unbound-method'] as TSESLint.RuleModule< 30 | MESSAGE_IDS, 31 | Options 32 | > 33 | } catch (e: unknown) { 34 | const error = e as { code: string } 35 | 36 | if (error.code === 'MODULE_NOT_FOUND') 37 | return null 38 | 39 | throw error 40 | } 41 | })() 42 | 43 | export default createEslintRule({ 44 | defaultOptions: [{ ignoreStatic: false }], 45 | name: RULE_NAME, 46 | meta: { 47 | messages: { 48 | unbound: DEFAULT_MESSAGE, 49 | unboundWithoutThisAnnotation: DEFAULT_MESSAGE 50 | }, 51 | schema: [], 52 | type: 'problem', 53 | ...baseRule?.meta, 54 | docs: { 55 | ...baseRule?.meta.docs, 56 | description: 'Enforce unbound methods are called with their expected scope', 57 | recommended: 'error', 58 | requiresTypeChecking: true 59 | } 60 | }, 61 | create(context) { 62 | const baseSelectors = baseRule?.create(context) 63 | 64 | if (!baseSelectors) return {} 65 | 66 | return { 67 | ...baseSelectors, 68 | MemberExpression(node: TSESTree.MemberExpression) { 69 | if (node.parent?.type === AST_NODE_TYPES.CallExpression) { 70 | const vitestFnCall = parseVitestFnCall( 71 | findTopMostCallExpression(node.parent), 72 | context 73 | ) 74 | 75 | if (vitestFnCall?.type === 'expect') { 76 | const { matcher } = vitestFnCall 77 | 78 | if (!toThrowMatchers.includes(getAccessorValue(matcher))) return 79 | } 80 | } 81 | baseSelectors?.MemberExpression?.(node) 82 | } 83 | } 84 | } 85 | }) 86 | -------------------------------------------------------------------------------- /src/rules/no-identical-title.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule, getStringValue, isStringNode, isSupportedAccessor } from '../utils' 2 | import { isTypeOfVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'no-identical-title' 5 | export type MESSAGE_ID = 'multipleTestTitle' | 'multipleDescribeTitle'; 6 | export type Options = []; 7 | 8 | interface DescribeContext { 9 | describeTitles: string[]; 10 | testTitles: string[]; 11 | } 12 | 13 | const newDescribeContext = (): DescribeContext => ({ 14 | describeTitles: [], 15 | testTitles: [] 16 | }) 17 | 18 | export default createEslintRule({ 19 | name: RULE_NAME, 20 | meta: { 21 | type: 'problem', 22 | docs: { 23 | description: 'Disallow identical titles', 24 | recommended: 'strict' 25 | }, 26 | fixable: 'code', 27 | schema: [], 28 | messages: { 29 | multipleTestTitle: 'Test is used multiple times in the same describe block', 30 | multipleDescribeTitle: 'Describe is used multiple times in the same describe block' 31 | } 32 | }, 33 | defaultOptions: [], 34 | create(context) { 35 | const stack = [newDescribeContext()] 36 | 37 | return { 38 | CallExpression(node) { 39 | const currentStack = stack[stack.length - 1] 40 | 41 | const vitestFnCall = parseVitestFnCall(node, context) 42 | 43 | if (!vitestFnCall) 44 | return 45 | 46 | if (vitestFnCall.name === 'describe') 47 | stack.push(newDescribeContext()) 48 | 49 | if (vitestFnCall.members.find(s => isSupportedAccessor(s, 'each'))) 50 | return 51 | 52 | const [argument] = node.arguments 53 | 54 | if (!argument || !isStringNode(argument)) 55 | return 56 | 57 | const title = getStringValue(argument) 58 | 59 | if (vitestFnCall.type === 'test') { 60 | if (currentStack.testTitles.includes(title)) { 61 | context.report({ 62 | node, 63 | messageId: 'multipleTestTitle' 64 | }) 65 | } 66 | currentStack.testTitles.push(title) 67 | } 68 | 69 | if (vitestFnCall.type !== 'describe') 70 | return 71 | 72 | if (currentStack?.describeTitles.includes(title)) { 73 | context.report({ 74 | node, 75 | messageId: 'multipleDescribeTitle' 76 | }) 77 | } 78 | currentStack?.describeTitles.push(title) 79 | }, 80 | 'CallExpression:exit'(node) { 81 | if (isTypeOfVitestFnCall(node, context, ['describe'])) 82 | stack.pop() 83 | } 84 | } 85 | } 86 | }) 87 | -------------------------------------------------------------------------------- /tests/prefer-todo.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/prefer-todo' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'test()', 7 | 'test.concurrent()', 8 | 'test.todo("i need to write this test");', 9 | 'test(obj)', 10 | 'test.concurrent(obj)', 11 | 'fit("foo")', 12 | 'fit.concurrent("foo")', 13 | 'xit("foo")', 14 | 'test("foo", 1)', 15 | 'test("stub", () => expect(1).toBe(1));', 16 | 'test.concurrent("stub", () => expect(1).toBe(1));', 17 | ` 18 | supportsDone && params.length < test.length 19 | ? done => test(...params, done) 20 | : () => test(...params); 21 | ` 22 | ], 23 | invalid: [ 24 | { 25 | code: 'test("i need to write this test");', 26 | output: 'test.todo("i need to write this test");', 27 | errors: [{ messageId: 'unimplementedTest' }] 28 | }, 29 | { 30 | code: 'test("i need to write this test",);', 31 | output: 'test.todo("i need to write this test",);', 32 | parserOptions: { ecmaVersion: 2017 }, 33 | errors: [{ messageId: 'unimplementedTest' }] 34 | }, 35 | { 36 | code: 'test(`i need to write this test`);', 37 | output: 'test.todo(`i need to write this test`);', 38 | errors: [{ messageId: 'unimplementedTest' }] 39 | }, 40 | { 41 | code: 'it("foo", function () {})', 42 | output: 'it.todo("foo")', 43 | errors: [{ messageId: 'emptyTest' }] 44 | }, 45 | { 46 | code: 'it("foo", () => {})', 47 | output: 'it.todo("foo")', 48 | errors: [{ messageId: 'emptyTest' }] 49 | }, 50 | { 51 | code: 'test.skip("i need to write this test", () => {});', 52 | output: 'test.todo("i need to write this test");', 53 | errors: [{ messageId: 'emptyTest' }] 54 | }, 55 | { 56 | code: 'test.skip("i need to write this test", function() {});', 57 | output: 'test.todo("i need to write this test");', 58 | errors: [{ messageId: 'emptyTest' }] 59 | }, 60 | { 61 | code: 'test["skip"]("i need to write this test", function() {});', 62 | output: 'test[\'todo\']("i need to write this test");', 63 | errors: [{ messageId: 'emptyTest' }] 64 | }, 65 | { 66 | code: 'test[`skip`]("i need to write this test", function() {});', 67 | output: 'test[\'todo\']("i need to write this test");', 68 | errors: [{ messageId: 'emptyTest' }] 69 | } 70 | ] 71 | }) 72 | -------------------------------------------------------------------------------- /src/rules/no-disabled-tests.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule, getAccessorValue } from '../utils' 2 | import { parseVitestFnCall, resolveScope } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'no-disabled-tests' 5 | export type MESSAGE_ID = 'missingFunction' | 'pending' | 'pendingSuite' | 'pendingTest' | 'disabledSuite' | 'disabledTest'; 6 | export type Options = []; 7 | 8 | export default createEslintRule({ 9 | name: RULE_NAME, 10 | meta: { 11 | type: 'suggestion', 12 | docs: { 13 | description: 'Disallow disabled tests', 14 | recommended: false 15 | }, 16 | messages: { 17 | missingFunction: 'Test is missing function argument', 18 | pending: 'Call to pending()', 19 | pendingSuite: 'Call to pending() within test suite', 20 | pendingTest: 'Call to pending() within test', 21 | disabledSuite: 'Disabled test suite', 22 | disabledTest: 'Disabled test' 23 | }, 24 | schema: [] 25 | }, 26 | defaultOptions: [], 27 | create(context) { 28 | let suiteDepth = 0 29 | let testDepth = 0 30 | 31 | return { 32 | CallExpression(node) { 33 | const vitestFnCall = parseVitestFnCall(node, context) 34 | 35 | if (!vitestFnCall) return 36 | 37 | if (vitestFnCall.type === 'describe') 38 | suiteDepth++ 39 | 40 | if (vitestFnCall.type === 'test') { 41 | testDepth++ 42 | 43 | if (node.arguments.length < 2 && vitestFnCall.members.every(s => getAccessorValue(s) === 'skip')) { 44 | context.report({ 45 | messageId: 'missingFunction', 46 | node 47 | }) 48 | } 49 | } 50 | 51 | if (vitestFnCall.name.startsWith('x') || vitestFnCall.members.some(s => getAccessorValue(s) === 'skip')) { 52 | context.report({ 53 | messageId: vitestFnCall.type === 'describe' ? 'disabledSuite' : 'disabledTest', 54 | node 55 | }) 56 | } 57 | }, 58 | 'CallExpression:exit'(node) { 59 | const vitestFnCall = parseVitestFnCall(node, context) 60 | 61 | if (!vitestFnCall) 62 | return 63 | 64 | if (vitestFnCall.type === 'describe') 65 | suiteDepth-- 66 | 67 | if (vitestFnCall.type === 'test') 68 | testDepth-- 69 | }, 70 | 'CallExpression[callee.name="pending"]'(node) { 71 | if (resolveScope(context.getScope(), 'pending')) 72 | return 73 | 74 | if (testDepth > 0) 75 | context.report({ messageId: 'pendingTest', node }) 76 | else if (suiteDepth > 0) 77 | context.report({ messageId: 'pendingSuite', node }) 78 | else 79 | context.report({ messageId: 'pending', node }) 80 | } 81 | } 82 | } 83 | }) 84 | -------------------------------------------------------------------------------- /tests/prefer-to-be-object.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/prefer-to-be-object' 2 | import { ruleTester } from './ruleTester' 3 | 4 | const messageId = 'preferToBeObject' 5 | 6 | ruleTester.run(RULE_NAME, rule, { 7 | valid: [ 8 | 'expectTypeOf.hasAssertions', 9 | 'expectTypeOf.hasAssertions()', 10 | 'expectTypeOf', 11 | 'expectTypeOf().not', 12 | 'expectTypeOf().toBe', 13 | 'expectTypeOf().toBe(true)', 14 | 'expectTypeOf({}).toBe(true)', 15 | 'expectTypeOf({}).toBeObject()', 16 | 'expectTypeOf({}).not.toBeObject()', 17 | 'expectTypeOf([] instanceof Array).not.toBeObject()', 18 | 'expectTypeOf({}).not.toBeInstanceOf(Array)' 19 | ], 20 | invalid: [ 21 | { 22 | code: 'expectTypeOf(({} instanceof Object)).toBeTruthy();', 23 | output: 'expectTypeOf(({})).toBeObject();', 24 | errors: [{ messageId: 'preferToBeObject', column: 38, line: 1 }] 25 | }, 26 | { 27 | code: 'expectTypeOf({} instanceof Object).toBeTruthy();', 28 | output: 'expectTypeOf({}).toBeObject();', 29 | errors: [{ messageId, column: 36, line: 1 }] 30 | }, 31 | { 32 | code: 'expectTypeOf({} instanceof Object).not.toBeTruthy();', 33 | output: 'expectTypeOf({}).not.toBeObject();', 34 | errors: [{ messageId, column: 40, line: 1 }] 35 | }, 36 | { 37 | code: 'expectTypeOf({} instanceof Object).toBeFalsy();', 38 | output: 'expectTypeOf({}).not.toBeObject();', 39 | errors: [{ messageId, column: 36, line: 1 }] 40 | }, 41 | { 42 | code: 'expectTypeOf({} instanceof Object).not.toBeFalsy();', 43 | output: 'expectTypeOf({}).toBeObject();', 44 | errors: [{ messageId, column: 40, line: 1 }] 45 | }, 46 | { 47 | code: 'expectTypeOf({}).toBeInstanceOf(Object);', 48 | output: 'expectTypeOf({}).toBeObject();', 49 | errors: [{ messageId, column: 18, line: 1 }] 50 | }, 51 | { 52 | code: 'expectTypeOf({}).not.toBeInstanceOf(Object);', 53 | output: 'expectTypeOf({}).not.toBeObject();', 54 | errors: [{ messageId, column: 22, line: 1 }] 55 | }, 56 | { 57 | code: 'expectTypeOf(requestValues()).resolves.toBeInstanceOf(Object);', 58 | output: 'expectTypeOf(requestValues()).resolves.toBeObject();', 59 | errors: [{ messageId, column: 40, line: 1 }] 60 | }, 61 | { 62 | code: 'expectTypeOf(queryApi()).resolves.not.toBeInstanceOf(Object);', 63 | output: 'expectTypeOf(queryApi()).resolves.not.toBeObject();', 64 | errors: [{ messageId, column: 39, line: 1 }] 65 | } 66 | ] 67 | }) 68 | -------------------------------------------------------------------------------- /src/rules/require-top-level-describe.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule } from '../utils' 2 | import { isTypeOfVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'require-top-level-describe' 5 | 6 | type MESSAGE_IDS = 7 | | 'tooManyDescribes' 8 | | 'unexpectedTestCase' 9 | | 'unexpectedHook' 10 | 11 | type Options = [Partial<{ maxNumberOfTopLevelDescribes: number }>] 12 | 13 | export default createEslintRule({ 14 | name: RULE_NAME, 15 | meta: { 16 | docs: { 17 | description: 'Enforce that all tests are in a top-level describe', 18 | recommended: 'warn' 19 | }, 20 | messages: { 21 | tooManyDescribes: 22 | 'There should not be more than {{ max }} describe{{ s }} at the top level', 23 | unexpectedTestCase: 'All test cases must be wrapped in a describe block.', 24 | unexpectedHook: 'All hooks must be wrapped in a describe block.' 25 | }, 26 | type: 'suggestion', 27 | schema: [ 28 | { 29 | type: 'object', 30 | properties: { 31 | maxNumberOfTopLevelDescribes: { 32 | type: 'number', 33 | minimum: 1 34 | } 35 | }, 36 | additionalProperties: false 37 | } 38 | ] 39 | }, 40 | defaultOptions: [{}], 41 | create(context) { 42 | const { maxNumberOfTopLevelDescribes = Infinity } = context.options[0] ?? {} 43 | 44 | let numberOfTopLevelDescribeBlocks = 0 45 | let numberOfDescribeBlocks = 0 46 | return { 47 | CallExpression(node) { 48 | const vitestFnCall = parseVitestFnCall(node, context) 49 | 50 | if (!vitestFnCall) return 51 | 52 | if (vitestFnCall.type === 'describe') { 53 | numberOfDescribeBlocks++ 54 | 55 | if (numberOfDescribeBlocks === 1) { 56 | numberOfTopLevelDescribeBlocks++ 57 | if (numberOfTopLevelDescribeBlocks > maxNumberOfTopLevelDescribes) { 58 | context.report({ 59 | node, 60 | messageId: 'tooManyDescribes', 61 | data: { 62 | max: maxNumberOfTopLevelDescribes, 63 | s: maxNumberOfTopLevelDescribes === 1 ? '' : 's' 64 | } 65 | }) 66 | } 67 | } 68 | return 69 | } 70 | 71 | if (numberOfDescribeBlocks === 0) { 72 | if (vitestFnCall.type === 'test') { 73 | context.report({ node, messageId: 'unexpectedTestCase' }) 74 | return 75 | } 76 | 77 | if (vitestFnCall.type === 'hook') 78 | context.report({ node, messageId: 'unexpectedHook' }) 79 | } 80 | }, 81 | 'CallExpression:exit'(node) { 82 | if (isTypeOfVitestFnCall(node, context, ['describe'])) 83 | numberOfDescribeBlocks-- 84 | } 85 | } 86 | } 87 | }) 88 | -------------------------------------------------------------------------------- /src/rules/prefer-todo.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule, getAccessorValue, isFunction, isStringNode, replaceAccessorFixer } from '../utils' 3 | import { ParsedVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | import { hasOnlyOneArgument } from '../utils/msc' 5 | 6 | export const RULE_NAME = 'prefer-todo' 7 | type MESSAGE_IDS = 'emptyTest' | 'unimplementedTest' 8 | type Options = [] 9 | 10 | const isTargetedTestCase = (vitestFnCall: ParsedVitestFnCall) => { 11 | if (vitestFnCall.members.some(s => getAccessorValue(s) !== 'skip')) return false 12 | 13 | if (vitestFnCall.name.startsWith('x')) 14 | return false 15 | 16 | return !vitestFnCall.name.startsWith('f') 17 | } 18 | 19 | function isEmptyFunction(node: TSESTree.CallExpressionArgument) { 20 | if (!isFunction(node)) 21 | return false 22 | 23 | return (node.body.type === AST_NODE_TYPES.BlockStatement && !node.body.body.length) 24 | } 25 | 26 | function createTodoFixer(vitestFnCall: ParsedVitestFnCall, fixer: TSESLint.RuleFixer) { 27 | if (vitestFnCall.members.length) 28 | return replaceAccessorFixer(fixer, vitestFnCall.members[0], 'todo') 29 | 30 | return fixer.replaceText(vitestFnCall.head.node, `${vitestFnCall.head.local}.todo`) 31 | } 32 | 33 | export default createEslintRule({ 34 | name: RULE_NAME, 35 | meta: { 36 | type: 'layout', 37 | docs: { 38 | description: 'Suggest using `test.todo`', 39 | recommended: 'warn' 40 | }, 41 | messages: { 42 | emptyTest: 'Prefer todo test case over empty test case', 43 | unimplementedTest: 'Prefer todo test case over unimplemented test case' 44 | }, 45 | fixable: 'code', 46 | schema: [] 47 | }, 48 | defaultOptions: [], 49 | create(context) { 50 | return { 51 | CallExpression(node) { 52 | const [title, callback] = node.arguments 53 | 54 | const vitestFnCall = parseVitestFnCall(node, context) 55 | 56 | if (!title || 57 | vitestFnCall?.type !== 'test' || 58 | !isTargetedTestCase(vitestFnCall) || 59 | !isStringNode(title)) 60 | return 61 | 62 | if (callback && isEmptyFunction(callback)) { 63 | context.report({ 64 | messageId: 'emptyTest', 65 | node, 66 | fix: fixer => [ 67 | fixer.removeRange([title.range[1], callback.range[1]]), 68 | createTodoFixer(vitestFnCall, fixer) 69 | ] 70 | }) 71 | } 72 | 73 | if (hasOnlyOneArgument(node)) { 74 | context.report({ 75 | messageId: 'unimplementedTest', 76 | node, 77 | fix: fixer => createTodoFixer(vitestFnCall, fixer) 78 | }) 79 | } 80 | } 81 | } 82 | } 83 | }) 84 | -------------------------------------------------------------------------------- /tests/no-interpolation-in-snapshots.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-interpolation-in-snapshots' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'expect("something").toEqual("else");', 7 | 'expect(something).toMatchInlineSnapshot();', 8 | 'expect(something).toMatchInlineSnapshot(`No interpolation`);', 9 | 'expect(something).toMatchInlineSnapshot({}, `No interpolation`);', 10 | 'expect(something);', 11 | 'expect(something).not;', 12 | 'expect.toHaveAssertions();', 13 | // eslint-disable-next-line no-template-curly-in-string 14 | 'myObjectWants.toMatchInlineSnapshot({}, `${interpolated}`);', 15 | // eslint-disable-next-line no-template-curly-in-string 16 | 'myObjectWants.toMatchInlineSnapshot({}, `${interpolated1} ${interpolated2}`);', 17 | // eslint-disable-next-line no-template-curly-in-string 18 | 'toMatchInlineSnapshot({}, `${interpolated}`);', 19 | // eslint-disable-next-line no-template-curly-in-string 20 | 'toMatchInlineSnapshot({}, `${interpolated1} ${interpolated2}`);', 21 | 'expect(something).toThrowErrorMatchingInlineSnapshot();', 22 | 'expect(something).toThrowErrorMatchingInlineSnapshot(`No interpolation`);' 23 | ], 24 | invalid: [ 25 | { 26 | // eslint-disable-next-line no-template-curly-in-string 27 | code: 'expect(something).toMatchInlineSnapshot(`${interpolated}`);', 28 | errors: [ 29 | { 30 | messageId: 'noInterpolationInSnapshots', 31 | column: 41, 32 | line: 1 33 | } 34 | ] 35 | }, 36 | { 37 | // eslint-disable-next-line no-template-curly-in-string 38 | code: 'expect(something).not.toMatchInlineSnapshot(`${interpolated}`);', 39 | errors: [ 40 | { 41 | messageId: 'noInterpolationInSnapshots', 42 | column: 45, 43 | line: 1 44 | } 45 | ] 46 | }, 47 | { 48 | // eslint-disable-next-line no-template-curly-in-string 49 | code: 'expect(something).toThrowErrorMatchingInlineSnapshot(`${interpolated}`);', 50 | errors: [ 51 | { 52 | endColumn: 71, 53 | column: 54, 54 | messageId: 'noInterpolationInSnapshots' 55 | } 56 | ] 57 | }, 58 | { 59 | // eslint-disable-next-line no-template-curly-in-string 60 | code: 'expect(something).not.toThrowErrorMatchingInlineSnapshot(`${interpolated}`);', 61 | errors: [ 62 | { 63 | endColumn: 75, 64 | column: 58, 65 | messageId: 'noInterpolationInSnapshots' 66 | } 67 | ] 68 | } 69 | ] 70 | }) 71 | -------------------------------------------------------------------------------- /tests/prefer-to-have-length.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/prefer-to-have-length' 2 | import { ruleTester } from './ruleTester' 3 | 4 | const messageId = 'preferToHaveLength' 5 | 6 | ruleTester.run(RULE_NAME, rule, { 7 | valid: [ 8 | 'expect.hasAssertions', 9 | 'expect.hasAssertions()', 10 | 'expect(files).toHaveLength(1);', 11 | 'expect(files.name).toBe(\'file\');', 12 | 'expect(files[`name`]).toBe(\'file\');', 13 | 'expect(users[0]?.permissions?.length).toBe(1);', 14 | 'expect(result).toBe(true);', 15 | 'expect(user.getUserName(5)).resolves.toEqual(\'Paul\')', 16 | 'expect(user.getUserName(5)).rejects.toEqual(\'Paul\')', 17 | 'expect(a);', 18 | 'expect().toBe();' 19 | ], 20 | invalid: [ 21 | { 22 | code: 'expect(files["length"]).toBe(1);', 23 | output: 'expect(files).toHaveLength(1);', 24 | errors: [{ messageId, column: 25, line: 1 }] 25 | }, 26 | { 27 | code: 'expect(files["length"]).toBe(1,);', 28 | output: 'expect(files).toHaveLength(1,);', 29 | parserOptions: { ecmaVersion: 2017 }, 30 | errors: [{ messageId, column: 25, line: 1 }] 31 | }, 32 | { 33 | code: 'expect(files["length"])["not"].toBe(1);', 34 | output: 'expect(files)["not"].toHaveLength(1);', 35 | errors: [{ messageId, column: 32, line: 1 }] 36 | }, 37 | { 38 | code: 'expect(files["length"])["toBe"](1);', 39 | output: 'expect(files).toHaveLength(1);', 40 | errors: [{ messageId, column: 25, line: 1 }] 41 | }, 42 | { 43 | code: 'expect(files["length"]).not["toBe"](1);', 44 | output: 'expect(files).not.toHaveLength(1);', 45 | errors: [{ messageId, column: 29, line: 1 }] 46 | }, 47 | { 48 | code: 'expect(files["length"])["not"]["toBe"](1);', 49 | output: 'expect(files)["not"].toHaveLength(1);', 50 | errors: [{ messageId, column: 32, line: 1 }] 51 | }, 52 | { 53 | code: 'expect(files.length).toBe(1);', 54 | output: 'expect(files).toHaveLength(1);', 55 | errors: [{ messageId, column: 22, line: 1 }] 56 | }, 57 | { 58 | code: 'expect(files.length).toEqual(1);', 59 | output: 'expect(files).toHaveLength(1);', 60 | errors: [{ messageId, column: 22, line: 1 }] 61 | }, 62 | { 63 | code: 'expect(files.length).toStrictEqual(1);', 64 | output: 'expect(files).toHaveLength(1);', 65 | errors: [{ messageId, column: 22, line: 1 }] 66 | }, 67 | { 68 | code: 'expect(files.length).not.toStrictEqual(1);', 69 | output: 'expect(files).not.toHaveLength(1);', 70 | errors: [{ messageId, column: 26, line: 1 }] 71 | } 72 | ] 73 | }) 74 | -------------------------------------------------------------------------------- /tests/prefer-comparison-matcher.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/prefer-comparison-matcher' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'expect.hasAssertions', 7 | 'expect.hasAssertions()', 8 | 'expect.assertions(1)', 9 | 'expect(true).toBe(...true)', 10 | 'expect()', 11 | 'expect({}).toStrictEqual({})', 12 | 'expect(a === b).toBe(true)', 13 | 'expect(a !== 2).toStrictEqual(true)', 14 | 'expect(a === b).not.toEqual(true)', 15 | 'expect(a !== "string").toStrictEqual(true)', 16 | 'expect(5 != a).toBe(true)', 17 | 'expect(a == "string").toBe(true)', 18 | 'expect(a == "string").not.toBe(true)', 19 | 'expect().fail(\'Should not succeed a HTTPS proxy request.\');' 20 | ], 21 | invalid: [ 22 | { 23 | code: 'expect(a > b).toBe(true)', 24 | output: 'expect(a).toBeGreaterThan(b)', 25 | errors: [ 26 | { 27 | messageId: 'useToBeComparison', 28 | data: { 29 | preferredMatcher: 'toBeGreaterThan' 30 | } 31 | } 32 | ] 33 | }, 34 | { 35 | code: 'expect(a < b).toBe(true)', 36 | output: 'expect(a).toBeLessThan(b)', 37 | errors: [ 38 | { 39 | messageId: 'useToBeComparison', 40 | data: { 41 | preferredMatcher: 'toBeLessThan' 42 | } 43 | } 44 | ] 45 | }, 46 | { 47 | code: 'expect(a >= b).toBe(true)', 48 | output: 'expect(a).toBeGreaterThanOrEqual(b)', 49 | errors: [ 50 | { 51 | messageId: 'useToBeComparison', 52 | data: { 53 | preferredMatcher: 'toBeGreaterThanOrEqual' 54 | } 55 | } 56 | ] 57 | }, 58 | { 59 | code: 'expect(a <= b).toBe(true)', 60 | output: 'expect(a).toBeLessThanOrEqual(b)', 61 | errors: [ 62 | { 63 | messageId: 'useToBeComparison' 64 | } 65 | ] 66 | }, 67 | { 68 | code: 'expect(a > b).not.toBe(true)', 69 | output: 'expect(a).toBeLessThanOrEqual(b)', 70 | errors: [ 71 | { 72 | messageId: 'useToBeComparison' 73 | } 74 | ] 75 | }, 76 | { 77 | code: 'expect(a < b).not.toBe(true)', 78 | output: 'expect(a).toBeGreaterThanOrEqual(b)', 79 | errors: [ 80 | { 81 | messageId: 'useToBeComparison' 82 | } 83 | ] 84 | }, 85 | { 86 | code: 'expect(a >= b).not.toBe(true)', 87 | output: 'expect(a).toBeLessThan(b)', 88 | errors: [ 89 | { 90 | messageId: 'useToBeComparison' 91 | } 92 | ] 93 | } 94 | ] 95 | }) 96 | -------------------------------------------------------------------------------- /tests/no-disabled-tests.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-disabled-tests' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'describe("foo", function () {})', 7 | 'it("foo", function () {})', 8 | 'describe.only("foo", function () {})', 9 | 'it.only("foo", function () {})', 10 | 'it.each("foo", () => {})', 11 | 'it.concurrent("foo", function () {})', 12 | 'test("foo", function () {})', 13 | 'test.only("foo", function () {})', 14 | 'test.concurrent("foo", function () {})', 15 | // eslint-disable-next-line no-template-curly-in-string 16 | 'describe[`${"skip"}`]("foo", function () {})', 17 | 'it.todo("fill this later")', 18 | 'var appliedSkip = describe.skip; appliedSkip.apply(describe)', 19 | 'var calledSkip = it.skip; calledSkip.call(it)', 20 | '({ f: function () {} }).f()', 21 | '(a || b).f()', 22 | 'itHappensToStartWithIt()', 23 | 'testSomething()', 24 | 'xitSomethingElse()', 25 | 'xitiViewMap()', 26 | `import { pending } from "actions" 27 | 28 | test("foo", () => { 29 | expect(pending()).toEqual({}) 30 | })`, 31 | ` import { test } from './test-utils'; 32 | 33 | test('something');` 34 | ], 35 | invalid: [ 36 | { 37 | code: 'describe.skip("foo", function () {})', 38 | errors: [ 39 | { 40 | messageId: 'disabledSuite' 41 | } 42 | ] 43 | }, 44 | { 45 | code: 'it("contains a call to pending", function () { pending() })', 46 | errors: [{ messageId: 'pendingTest', column: 48, line: 1 }] 47 | }, 48 | { 49 | code: 'xtest("foo", function () {})', 50 | errors: [{ messageId: 'disabledTest', column: 1, line: 1 }] 51 | }, 52 | { 53 | code: 'xit.each``("foo", function () {})', 54 | errors: [{ messageId: 'disabledTest', column: 1, line: 1 }] 55 | }, 56 | { 57 | code: 'xtest.each``("foo", function () {})', 58 | errors: [{ messageId: 'disabledTest', column: 1, line: 1 }] 59 | }, 60 | { 61 | code: 'xit.each([])("foo", function () {})', 62 | errors: [{ messageId: 'disabledTest', column: 1, line: 1 }] 63 | }, 64 | { 65 | code: 'it("has title but no callback")', 66 | errors: [{ messageId: 'missingFunction', column: 1, line: 1 }] 67 | }, 68 | { 69 | code: 'test("has title but no callback")', 70 | errors: [{ messageId: 'missingFunction', column: 1, line: 1 }] 71 | }, 72 | { 73 | code: 'it("contains a call to pending", function () { pending() })', 74 | errors: [{ messageId: 'pendingTest', column: 48, line: 1 }] 75 | }, 76 | { 77 | code: 'pending();', 78 | errors: [{ messageId: 'pending', column: 1, line: 1 }] 79 | } 80 | ] 81 | }) 82 | -------------------------------------------------------------------------------- /tests/expect-expect.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/expect-expect' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | { 7 | code: 'test("shows success", () => {expectValue(true).toBe(false);});', 8 | options: [{ customExpressions: ['expectValue'] }] 9 | }, 10 | { 11 | code: `test("shows success", () => { 12 | mySecondExpression(true).toBe(true);});`, 13 | options: [ 14 | { customExpressions: ['expectValue', 'mySecondExpression'] } 15 | ] 16 | }, 17 | { 18 | code: 'test.skip("shows success", () => {});' 19 | } 20 | ], 21 | invalid: [ 22 | { 23 | code: 'test("shows error", () => {});', 24 | errors: [{ messageId: 'expectedExpect' }] 25 | } 26 | ] 27 | }) 28 | 29 | ruleTester.run(RULE_NAME, rule, { 30 | valid: [ 31 | `test("shows error", () => { 32 | expect(true).toBe(false); 33 | });`, 34 | `it("foo", function () { 35 | expect(true).toBe(false); 36 | })`, 37 | `it('foo', () => { 38 | expect(true).toBe(false); 39 | }); 40 | function myTest() { if ('bar') {} }`, 41 | `function myTest(param) {} 42 | describe('my test', () => { 43 | it('should do something', () => { 44 | myTest("num"); 45 | expect(1).toEqual(1); 46 | }); 47 | });`, 48 | `const myFunc = () => {}; 49 | it("works", () => expect(myFunc()).toBe(undefined));`, 50 | `describe('title', () => { 51 | it('test is not ok', () => { 52 | [1, 2, 3, 4, 5, 6].forEach((n) => { 53 | expect(n).toBe(1); 54 | }); 55 | }); 56 | });`, 57 | `describe('title', () => { 58 | test('some test', () => { 59 | expect(obj1).not.toEqual(obj2); 60 | }) 61 | })`, 62 | 'it("should pass", () => expect(true).toBeDefined())', 63 | `const myFunc = () => {}; 64 | it("works", () => expect(myFunc()).toBe(undefined));`, 65 | `const myFunc = () => {}; 66 | it("works", () => expect(myFunc()).toBe(undefined));`, 67 | 'it.skip("should also pass",() => {})' 68 | ], 69 | invalid: [ 70 | { 71 | code: `it("foo", function () { 72 | if (1 === 2) {} 73 | })`, 74 | errors: [{ messageId: 'expectedExpect' }] 75 | }, 76 | { 77 | code: `import { it } from 'vitest'; 78 | describe('Button with increment', () => { 79 | it('should show name props', () => { 80 | console.log('test with missing expect'); 81 | }); 82 | });`, 83 | errors: [{ messageId: 'expectedExpect' }] 84 | }, 85 | { 86 | code: 'it("should also fail",() => expectSaga(mySaga).returns());', 87 | errors: [{ messageId: 'expectedExpect' }] 88 | } 89 | ] 90 | }) 91 | -------------------------------------------------------------------------------- /tests/max-expect.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/max-expect' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'test(\'should pass\')', 7 | 'test(\'should pass\', () => {})', 8 | 'test.skip(\'should pass\', () => {})', 9 | `test('should pass', () => { 10 | expect(true).toBeDefined(); 11 | expect(true).toBeDefined(); 12 | expect(true).toBeDefined(); 13 | expect(true).toBeDefined(); 14 | expect(true).toBeDefined(); 15 | });`, 16 | `test('should pass', () => { 17 | expect(true).toBeDefined(); 18 | expect(true).toBeDefined(); 19 | expect(true).toBeDefined(); 20 | expect(true).toBeDefined(); 21 | expect(true).toBeDefined(); 22 | });`, 23 | ` test('should pass', async () => { 24 | expect.hasAssertions(); 25 | 26 | expect(true).toBeDefined(); 27 | expect(true).toBeDefined(); 28 | expect(true).toBeDefined(); 29 | expect(true).toBeDefined(); 30 | expect(true).toEqual(expect.any(Boolean)); 31 | });` 32 | ], 33 | invalid: [ 34 | { 35 | code: `test('should not pass', function () { 36 | expect(true).toBeDefined(); 37 | expect(true).toBeDefined(); 38 | expect(true).toBeDefined(); 39 | expect(true).toBeDefined(); 40 | expect(true).toBeDefined(); 41 | expect(true).toBeDefined(); 42 | }); 43 | `, 44 | errors: [ 45 | { 46 | messageId: 'maxExpect', 47 | line: 7, 48 | column: 8 49 | } 50 | ] 51 | }, 52 | { 53 | code: `test('should not pass', () => { 54 | expect(true).toBeDefined(); 55 | expect(true).toBeDefined(); 56 | expect(true).toBeDefined(); 57 | expect(true).toBeDefined(); 58 | expect(true).toBeDefined(); 59 | expect(true).toBeDefined(); 60 | }); 61 | test('should not pass', () => { 62 | expect(true).toBeDefined(); 63 | expect(true).toBeDefined(); 64 | expect(true).toBeDefined(); 65 | expect(true).toBeDefined(); 66 | expect(true).toBeDefined(); 67 | expect(true).toBeDefined(); 68 | });`, 69 | errors: [ 70 | { 71 | messageId: 'maxExpect', 72 | line: 7, 73 | column: 7 74 | }, 75 | { 76 | messageId: 'maxExpect', 77 | line: 15, 78 | column: 7 79 | } 80 | ] 81 | }, 82 | { 83 | code: `test('should not pass', () => { 84 | expect(true).toBeDefined(); 85 | expect(true).toBeDefined(); 86 | });`, 87 | options: [ 88 | { 89 | max: 1 90 | } 91 | ], 92 | errors: [ 93 | { 94 | messageId: 'maxExpect', 95 | line: 3, 96 | column: 7 97 | } 98 | ] 99 | } 100 | ] 101 | }) 102 | -------------------------------------------------------------------------------- /tests/max-expects.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/max-expects' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'test(\'should pass\')', 7 | 'test(\'should pass\', () => {})', 8 | 'test.skip(\'should pass\', () => {})', 9 | `test('should pass', () => { 10 | expect(true).toBeDefined(); 11 | expect(true).toBeDefined(); 12 | expect(true).toBeDefined(); 13 | expect(true).toBeDefined(); 14 | expect(true).toBeDefined(); 15 | });`, 16 | `test('should pass', () => { 17 | expect(true).toBeDefined(); 18 | expect(true).toBeDefined(); 19 | expect(true).toBeDefined(); 20 | expect(true).toBeDefined(); 21 | expect(true).toBeDefined(); 22 | });`, 23 | ` test('should pass', async () => { 24 | expect.hasAssertions(); 25 | 26 | expect(true).toBeDefined(); 27 | expect(true).toBeDefined(); 28 | expect(true).toBeDefined(); 29 | expect(true).toBeDefined(); 30 | expect(true).toEqual(expect.any(Boolean)); 31 | });` 32 | ], 33 | invalid: [ 34 | { 35 | code: `test('should not pass', function () { 36 | expect(true).toBeDefined(); 37 | expect(true).toBeDefined(); 38 | expect(true).toBeDefined(); 39 | expect(true).toBeDefined(); 40 | expect(true).toBeDefined(); 41 | expect(true).toBeDefined(); 42 | }); 43 | `, 44 | errors: [ 45 | { 46 | messageId: 'maxExpect', 47 | line: 7, 48 | column: 8 49 | } 50 | ] 51 | }, 52 | { 53 | code: `test('should not pass', () => { 54 | expect(true).toBeDefined(); 55 | expect(true).toBeDefined(); 56 | expect(true).toBeDefined(); 57 | expect(true).toBeDefined(); 58 | expect(true).toBeDefined(); 59 | expect(true).toBeDefined(); 60 | }); 61 | test('should not pass', () => { 62 | expect(true).toBeDefined(); 63 | expect(true).toBeDefined(); 64 | expect(true).toBeDefined(); 65 | expect(true).toBeDefined(); 66 | expect(true).toBeDefined(); 67 | expect(true).toBeDefined(); 68 | });`, 69 | errors: [ 70 | { 71 | messageId: 'maxExpect', 72 | line: 7, 73 | column: 7 74 | }, 75 | { 76 | messageId: 'maxExpect', 77 | line: 15, 78 | column: 7 79 | } 80 | ] 81 | }, 82 | { 83 | code: `test('should not pass', () => { 84 | expect(true).toBeDefined(); 85 | expect(true).toBeDefined(); 86 | });`, 87 | options: [ 88 | { 89 | max: 1 90 | } 91 | ], 92 | errors: [ 93 | { 94 | messageId: 'maxExpect', 95 | line: 3, 96 | column: 7 97 | } 98 | ] 99 | } 100 | ] 101 | }) 102 | -------------------------------------------------------------------------------- /src/rules/no-conditional-expect.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule, isSupportedAccessor, KnownCallExpression } from '../utils' 3 | import { getTestCallExpressionsFromDeclaredVariables, isTypeOfVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'no-conditional-expect' 6 | export type MESSAGE_ID = 'noConditionalExpect'; 7 | export type Options = []; 8 | 9 | const isCatchCall = ( 10 | node: TSESTree.CallExpression 11 | ): node is KnownCallExpression<'catch'> => 12 | node.callee.type === AST_NODE_TYPES.MemberExpression && 13 | isSupportedAccessor(node.callee.property, 'catch') 14 | 15 | export default createEslintRule({ 16 | name: RULE_NAME, 17 | meta: { 18 | type: 'problem', 19 | docs: { 20 | description: 'Disallow conditional expects', 21 | requiresTypeChecking: false, 22 | recommended: 'error' 23 | }, 24 | messages: { 25 | noConditionalExpect: 'Avoid calling `expect` inside conditional statements' 26 | }, 27 | schema: [] 28 | }, 29 | defaultOptions: [], 30 | create(context) { 31 | let conditionalDepth = 0 32 | let inTestCase = false 33 | let inPromiseCatch = false 34 | 35 | const increaseConditionalDepth = () => inTestCase && conditionalDepth++ 36 | const decreaseConditionalDepth = () => inTestCase && conditionalDepth-- 37 | 38 | return { 39 | FunctionDeclaration(node) { 40 | const declaredVariables = context.getDeclaredVariables(node) 41 | const testCallExpressions = getTestCallExpressionsFromDeclaredVariables(declaredVariables, context) 42 | 43 | if (testCallExpressions.length > 0) 44 | inTestCase = true 45 | }, 46 | CallExpression(node: TSESTree.CallExpression) { 47 | const { type: vitestFnCallType } = parseVitestFnCall(node, context) ?? {} 48 | 49 | if (vitestFnCallType === 'test') 50 | inTestCase = true 51 | 52 | if (isCatchCall(node)) 53 | inPromiseCatch = true 54 | 55 | if (inTestCase && vitestFnCallType === 'expect' && conditionalDepth > 0) { 56 | context.report({ 57 | messageId: 'noConditionalExpect', 58 | node 59 | }) 60 | } 61 | 62 | if (inPromiseCatch && vitestFnCallType === 'expect') { 63 | context.report({ 64 | messageId: 'noConditionalExpect', 65 | node 66 | }) 67 | } 68 | }, 69 | 'CallExpression:exit'(node) { 70 | if (isTypeOfVitestFnCall(node, context, ['test'])) 71 | inTestCase = false 72 | 73 | if (isCatchCall(node)) 74 | inPromiseCatch = false 75 | }, 76 | CatchClause: increaseConditionalDepth, 77 | 'CatchClause:exit': decreaseConditionalDepth, 78 | IfStatement: increaseConditionalDepth, 79 | 'IfStatement:exit': decreaseConditionalDepth, 80 | SwitchStatement: increaseConditionalDepth, 81 | 'SwitchStatement:exit': decreaseConditionalDepth, 82 | ConditionalExpression: increaseConditionalDepth, 83 | 'ConditionalExpression:exit': decreaseConditionalDepth, 84 | LogicalExpression: increaseConditionalDepth, 85 | 'LogicalExpression:exit': decreaseConditionalDepth 86 | } 87 | } 88 | }) 89 | -------------------------------------------------------------------------------- /src/rules/prefer-to-contain.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' 2 | import { KnownCallExpression, createEslintRule, getAccessorValue, isSupportedAccessor } from '../utils' 3 | import { hasOnlyOneArgument, isBooleanLiteral } from '../utils/msc' 4 | import { getFirstMatcherArg, parseVitestFnCall } from '../utils/parseVitestFnCall' 5 | import { CallExpressionWithSingleArgument, EqualityMatcher, ModifierName } from '../utils/types' 6 | 7 | export const RULE_NAME = 'prefer-to-contain' 8 | type MESSAGE_IDS = 'useToContain'; 9 | type Options = [] 10 | 11 | type FixableIncludesCallExpression = KnownCallExpression<'includes'> & 12 | CallExpressionWithSingleArgument; 13 | 14 | const isFixableIncludesCallExpression = (node: TSESTree.Node): node is FixableIncludesCallExpression => 15 | node.type === AST_NODE_TYPES.CallExpression && 16 | node.callee.type === AST_NODE_TYPES.MemberExpression && 17 | isSupportedAccessor(node.callee.property, 'includes') && 18 | hasOnlyOneArgument(node) && 19 | node.arguments[0].type !== AST_NODE_TYPES.SpreadElement 20 | 21 | export default createEslintRule({ 22 | name: RULE_NAME, 23 | meta: { 24 | docs: { 25 | description: 'Prefer using toContain()', 26 | recommended: 'warn' 27 | }, 28 | messages: { 29 | useToContain: 'Use toContain() instead' 30 | }, 31 | fixable: 'code', 32 | type: 'suggestion', 33 | schema: [] 34 | }, 35 | defaultOptions: [], 36 | create(context) { 37 | return { 38 | CallExpression(node) { 39 | const vitestFnCall = parseVitestFnCall(node, context) 40 | 41 | if (vitestFnCall?.type !== 'expect' || vitestFnCall.args.length === 0) 42 | return 43 | 44 | const { parent: expect } = vitestFnCall.head.node 45 | 46 | if (expect?.type !== AST_NODE_TYPES.CallExpression) return 47 | 48 | const { 49 | arguments: [includesCall], 50 | range: [, expectCallEnd] 51 | } = expect 52 | 53 | const { matcher } = vitestFnCall 54 | const matcherArg = getFirstMatcherArg(vitestFnCall) 55 | 56 | if ( 57 | !includesCall || 58 | matcherArg.type === AST_NODE_TYPES.SpreadElement || 59 | // eslint-disable-next-line no-prototype-builtins 60 | !EqualityMatcher.hasOwnProperty(getAccessorValue(matcher)) || 61 | !isBooleanLiteral(matcherArg) || 62 | !isFixableIncludesCallExpression(includesCall)) 63 | return 64 | 65 | const hasNot = vitestFnCall.modifiers.some(nod => getAccessorValue(nod) === 'not') 66 | 67 | context.report({ 68 | fix(fixer) { 69 | const sourceCode = context.getSourceCode() 70 | 71 | const addNotModifier = matcherArg.value === hasNot 72 | 73 | return [ 74 | fixer.removeRange([ 75 | includesCall.callee.property.range[0] - 1, 76 | includesCall.range[1] 77 | ]), 78 | fixer.replaceTextRange([expectCallEnd, matcher.parent.range[1]], 79 | addNotModifier 80 | ? `.${ModifierName.not}.toContain` 81 | : '.toContain'), 82 | fixer.replaceText( 83 | vitestFnCall.args[0], 84 | sourceCode.getText(includesCall.arguments[0]) 85 | ) 86 | ] 87 | }, 88 | messageId: 'useToContain', 89 | node: matcher 90 | }) 91 | } 92 | } 93 | } 94 | }) 95 | -------------------------------------------------------------------------------- /src/rules/require-hook.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule, getNodeName, isFunction, isIdentifier } from '../utils' 3 | import { isTypeOfVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'require-hook' 6 | type MESSAGE_IDS = 'useHook' 7 | type Options = [{ allowedFunctionCalls?: readonly string[] }] 8 | 9 | const isVitestFnCall = ( 10 | node: TSESTree.CallExpression, 11 | context: TSESLint.RuleContext 12 | ) => { 13 | if (parseVitestFnCall(node, context)) 14 | return true 15 | 16 | return !!getNodeName(node)?.startsWith('vi') 17 | } 18 | 19 | const isNullOrUndefined = (node: TSESTree.Expression) => { 20 | return (node.type === AST_NODE_TYPES.Literal && node.value === null) || isIdentifier(node, 'undefined') 21 | } 22 | 23 | const shouldBeInHook = ( 24 | node: TSESTree.Node, 25 | context: TSESLint.RuleContext, 26 | allowedFunctionCalls: readonly string[] = [] 27 | ): boolean => { 28 | switch (node.type) { 29 | case AST_NODE_TYPES.ExpressionStatement: 30 | return shouldBeInHook(node.expression, context, allowedFunctionCalls) 31 | case AST_NODE_TYPES.CallExpression: 32 | return !(isVitestFnCall(node, context) || allowedFunctionCalls.includes(getNodeName(node) as string)) 33 | case AST_NODE_TYPES.VariableDeclaration: { 34 | if (node.kind === 'const') 35 | return false 36 | 37 | return node.declarations.some( 38 | ({ init }) => init !== null && !isNullOrUndefined(init) 39 | ) 40 | } 41 | default: 42 | return false 43 | } 44 | } 45 | 46 | export default createEslintRule({ 47 | name: RULE_NAME, 48 | meta: { 49 | docs: { 50 | description: 'Require setup and teardown to be within a hook', 51 | recommended: 'warn' 52 | }, 53 | messages: { 54 | useHook: 'This should be done within a hook' 55 | }, 56 | type: 'suggestion', 57 | schema: [ 58 | { 59 | type: 'object', 60 | properties: { 61 | allowedFunctionCalls: { 62 | type: 'array', 63 | items: { type: 'string' } 64 | } 65 | }, 66 | additionalProperties: false 67 | } 68 | ] 69 | }, 70 | defaultOptions: [ 71 | { 72 | allowedFunctionCalls: [] 73 | } 74 | ], 75 | create(context) { 76 | const { allowedFunctionCalls } = context.options[0] ?? {} 77 | 78 | const checkBlockBody = (body: TSESTree.BlockStatement['body']) => { 79 | for (const statement of body) { 80 | if (shouldBeInHook(statement, context, allowedFunctionCalls)) { 81 | context.report({ 82 | node: statement, 83 | messageId: 'useHook' 84 | }) 85 | } 86 | } 87 | } 88 | 89 | return { 90 | Program(program) { 91 | checkBlockBody(program.body) 92 | }, 93 | CallExpression(node) { 94 | if (!isTypeOfVitestFnCall(node, context, ['describe']) || node.arguments.length < 2) return 95 | 96 | const [, testFn] = node.arguments 97 | 98 | if (!isFunction(testFn) || 99 | testFn.body.type !== AST_NODE_TYPES.BlockStatement) return 100 | 101 | checkBlockBody(testFn.body.body) 102 | } 103 | } 104 | } 105 | }) 106 | -------------------------------------------------------------------------------- /src/rules/prefer-to-be-object.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils' 2 | import { createEslintRule, getAccessorValue, isParsedInstanceOfMatcherCall } from '../utils' 3 | import { isBooleanEqualityMatcher, isInstanceOfBinaryExpression } from '../utils/msc' 4 | import { followTypeAssertionChain, parseVitestFnCall } from '../utils/parseVitestFnCall' 5 | 6 | export const RULE_NAME = 'prefer-to-be-object' 7 | export type MESSAGE_IDS = 'preferToBeObject'; 8 | export type Options = [] 9 | 10 | export default createEslintRule({ 11 | name: RULE_NAME, 12 | meta: { 13 | type: 'suggestion', 14 | docs: { 15 | description: 'Prefer toBeObject()', 16 | recommended: 'error' 17 | }, 18 | fixable: 'code', 19 | messages: { 20 | preferToBeObject: 'Prefer toBeObject() to test if a value is an object.' 21 | }, 22 | schema: [] 23 | }, 24 | defaultOptions: [], 25 | create(context) { 26 | return { 27 | CallExpression(node) { 28 | const vitestFnCall = parseVitestFnCall(node, context) 29 | 30 | if (vitestFnCall?.type !== 'expectTypeOf') 31 | return 32 | 33 | if (isParsedInstanceOfMatcherCall(vitestFnCall, 'Object')) { 34 | context.report({ 35 | node: vitestFnCall.matcher, 36 | messageId: 'preferToBeObject', 37 | fix: fixer => [ 38 | fixer.replaceTextRange( 39 | [ 40 | vitestFnCall.matcher.range[0], 41 | vitestFnCall.matcher.range[1] + '(Object)'.length 42 | ], 43 | 'toBeObject()' 44 | ) 45 | ] 46 | }) 47 | return 48 | } 49 | 50 | const { parent: expectTypeOf } = vitestFnCall.head.node 51 | 52 | if (expectTypeOf?.type !== AST_NODE_TYPES.CallExpression) 53 | return 54 | 55 | const [expectTypeOfArgs] = expectTypeOf.arguments 56 | 57 | if (!expectTypeOfArgs || 58 | !isBooleanEqualityMatcher(vitestFnCall) || 59 | !isInstanceOfBinaryExpression(expectTypeOfArgs, 'Object')) 60 | return 61 | 62 | context.report({ 63 | node: vitestFnCall.matcher, 64 | messageId: 'preferToBeObject', 65 | fix(fixer) { 66 | const fixes = [ 67 | fixer.replaceText(vitestFnCall.matcher, 'toBeObject'), 68 | fixer.removeRange([expectTypeOfArgs.left.range[1], expectTypeOfArgs.range[1]]) 69 | ] 70 | 71 | let invertCondition = getAccessorValue(vitestFnCall.matcher) === 'toBeFalsy' 72 | 73 | if (vitestFnCall.args.length) { 74 | const [matcherArg] = vitestFnCall.args 75 | 76 | fixes.push(fixer.remove(matcherArg)) 77 | 78 | invertCondition = matcherArg.type === AST_NODE_TYPES.Literal && 79 | followTypeAssertionChain(matcherArg).value === false 80 | } 81 | 82 | if (invertCondition) { 83 | const notModifier = vitestFnCall.modifiers.find(node => getAccessorValue(node) === 'not') 84 | 85 | fixes.push(notModifier 86 | ? fixer.removeRange([ 87 | notModifier.range[0] - 1, 88 | notModifier.range[1] 89 | ]) 90 | : fixer.insertTextBefore(vitestFnCall.matcher, 'not.') 91 | ) 92 | } 93 | return fixes 94 | } 95 | }) 96 | } 97 | } 98 | } 99 | }) 100 | -------------------------------------------------------------------------------- /src/rules/prefer-mock-promise-shorthand.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' 2 | import { AccessorNode, createEslintRule, FunctionExpression, getAccessorValue, getNodeName, isFunction, isSupportedAccessor } from '../utils' 3 | 4 | export const RULE_NAME = 'prefer-mock-promise-shorthand' 5 | type MESSAGE_IDS = 'useMockShorthand'; 6 | type Options = []; 7 | 8 | const withOnce = (name: string, addOnce: boolean): string => { 9 | return `${name}${addOnce ? 'Once' : ''}` 10 | } 11 | 12 | const findSingleReturnArgumentNode = ( 13 | fnNode: FunctionExpression 14 | ): TSESTree.Expression | null => { 15 | if (fnNode.body.type !== AST_NODE_TYPES.BlockStatement) 16 | return fnNode.body 17 | 18 | if (fnNode.body.body[0]?.type === AST_NODE_TYPES.ReturnStatement) 19 | return fnNode.body.body[0].argument 20 | 21 | return null 22 | } 23 | 24 | export default createEslintRule({ 25 | name: RULE_NAME, 26 | meta: { 27 | type: 'suggestion', 28 | docs: { 29 | description: 'Prefer mock resolved/rejected shorthands for promises', 30 | recommended: 'warn' 31 | }, 32 | messages: { 33 | useMockShorthand: 'Prefer {{ replacement }}' 34 | }, 35 | schema: [], 36 | fixable: 'code' 37 | }, 38 | defaultOptions: [], 39 | create(context) { 40 | const report = ( 41 | property: AccessorNode, 42 | isOnce: boolean, 43 | outerArgNode: TSESTree.Node, 44 | innerArgNode: TSESTree.Node | null = outerArgNode 45 | ) => { 46 | if (innerArgNode?.type !== AST_NODE_TYPES.CallExpression) return 47 | 48 | const argName = getNodeName(innerArgNode) 49 | 50 | if (argName !== 'Promise.resolve' && argName !== 'Promise.reject') 51 | return 52 | 53 | const replacement = withOnce(argName.endsWith('reject') ? 'mockRejectedValue' : 'mockResolvedValue', isOnce) 54 | 55 | context.report({ 56 | node: property, 57 | messageId: 'useMockShorthand', 58 | data: { replacement }, 59 | fix(fixer) { 60 | const sourceCode = context.getSourceCode() 61 | 62 | if (innerArgNode.arguments.length > 1) 63 | return null 64 | 65 | return [ 66 | fixer.replaceText(property, replacement), 67 | fixer.replaceText(outerArgNode, innerArgNode.arguments.length === 1 ? sourceCode.getText(innerArgNode.arguments[0]) : 'undefined') 68 | ] 69 | } 70 | }) 71 | } 72 | 73 | return { 74 | CallExpression(node) { 75 | if (node.callee.type !== AST_NODE_TYPES.MemberExpression || 76 | !isSupportedAccessor(node.callee.property) || 77 | node.arguments.length === 0) 78 | return 79 | 80 | const mockFnName = getAccessorValue(node.callee.property) 81 | const isOnce = mockFnName.endsWith('Once') 82 | 83 | if (mockFnName === withOnce('mockReturnValue', isOnce)) { 84 | report(node.callee.property, isOnce, node.arguments[0]) 85 | } else if (mockFnName === withOnce('mockImplementation', isOnce)) { 86 | const [arg] = node.arguments 87 | 88 | if (!isFunction(arg) || arg.params.length !== 0) 89 | return 90 | 91 | report( 92 | node.callee.property, 93 | isOnce, 94 | arg, 95 | findSingleReturnArgumentNode(arg) 96 | ) 97 | } 98 | } 99 | } 100 | } 101 | }) 102 | -------------------------------------------------------------------------------- /src/rules/expect-expect.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule, getNodeName, isSupportedAccessor } from '../utils' 3 | import { getTestCallExpressionsFromDeclaredVariables, isTypeOfVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'expect-expect' 6 | export type MESSAGE_ID = 'expectedExpect'; 7 | type Options = [{customExpressions: string[]}] 8 | 9 | /** 10 | * Checks if node names returned by getNodeName matches any of the given star patterns 11 | * Pattern examples: 12 | * request.*.expect 13 | * request.**.expect 14 | * request.**.expect* 15 | */ 16 | function buildRegularExpression(pattern: string) { 17 | return new RegExp( 18 | `^${pattern 19 | .split('.') 20 | .map(x => { 21 | if (x === '**') return '[a-z\\d\\.]*' 22 | 23 | return x.replace(/\*/gu, '[a-z\\d]*') 24 | }) 25 | .join('\\.')}(\\.|$)`, 26 | 'ui') 27 | } 28 | 29 | function matchesAssertFunctionName( 30 | nodeName: string, 31 | patterns: readonly string[] 32 | ): boolean { 33 | return patterns.some(pattern => 34 | buildRegularExpression(pattern).test(nodeName) 35 | ) 36 | } 37 | 38 | export default createEslintRule({ 39 | name: RULE_NAME, 40 | meta: { 41 | type: 'suggestion', 42 | docs: { 43 | description: 'Enforce having expectation in test body', 44 | recommended: 'strict' 45 | }, 46 | schema: [ 47 | { 48 | type: 'object', 49 | properties: { 50 | customExpressions: { 51 | type: 'array' 52 | } 53 | }, 54 | additionalProperties: false 55 | } 56 | ], 57 | messages: { 58 | expectedExpect: 'Use {{ expected }} in test body' 59 | } 60 | }, 61 | defaultOptions: [{ customExpressions: ['expect'] }], 62 | create(context, [{ customExpressions }]) { 63 | const unchecked: TSESTree.CallExpression[] = [] 64 | 65 | function checkCallExpressionUsed(nodes: TSESTree.Node[]) { 66 | for (const node of nodes) { 67 | const index = node.type === AST_NODE_TYPES.CallExpression 68 | ? unchecked.indexOf(node) 69 | : -1 70 | 71 | if (node.type === AST_NODE_TYPES.FunctionDeclaration) { 72 | const declaredVariables = context.getDeclaredVariables(node) 73 | const textCallExpression = getTestCallExpressionsFromDeclaredVariables(declaredVariables, context) 74 | checkCallExpressionUsed(textCallExpression) 75 | } 76 | if (index !== -1) { 77 | unchecked.splice(index, 1) 78 | break 79 | } 80 | } 81 | } 82 | 83 | return { 84 | CallExpression(node) { 85 | const name = getNodeName(node) ?? '' 86 | 87 | if (isTypeOfVitestFnCall(node, context, ['test'])) { 88 | if (node.callee.type === AST_NODE_TYPES.MemberExpression && 89 | (isSupportedAccessor(node.callee.property, 'todo') || isSupportedAccessor(node.callee.property, 'skip'))) 90 | return 91 | 92 | unchecked.push(node) 93 | } else if (matchesAssertFunctionName(name, customExpressions)) { 94 | checkCallExpressionUsed(context.getAncestors()) 95 | } 96 | }, 97 | 'Program:exit'() { 98 | unchecked.forEach(node => context.report({ node, messageId: 'expectedExpect', data: { expected: customExpressions.join(' or ') } })) 99 | } 100 | } 101 | } 102 | }) 103 | -------------------------------------------------------------------------------- /src/rules/valid-describe-callback.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule, getAccessorValue, isFunction } from '../utils' 3 | import { parseVitestFnCall } from '../utils/parseVitestFnCall' 4 | 5 | export const RULE_NAME = 'valid-describe-callback' 6 | type MESSAGE_IDS = 7 | | 'nameAndCallback' 8 | | 'secondArgumentMustBeFunction' 9 | | 'noAsyncDescribeCallback' 10 | | 'unexpectedDescribeArgument' 11 | | 'unexpectedReturnInDescribe' 12 | 13 | type Options = []; 14 | 15 | const paramsLocation = (params: TSESTree.CallExpressionArgument[] | TSESTree.Parameter[]) => { 16 | const [first] = params 17 | const last = params[params.length - 1] 18 | 19 | return { 20 | start: first.loc.start, 21 | end: last.loc.end 22 | } 23 | } 24 | 25 | export default createEslintRule({ 26 | name: RULE_NAME, 27 | meta: { 28 | type: 'problem', 29 | docs: { 30 | description: 'Enforce valid describe callback', 31 | recommended: 'strict' 32 | }, 33 | messages: { 34 | nameAndCallback: 'Describe requires a name and callback arguments', 35 | secondArgumentMustBeFunction: 'Second argument must be a function', 36 | noAsyncDescribeCallback: 'Describe callback cannot be async', 37 | unexpectedDescribeArgument: 'Unexpected argument in describe callback', 38 | unexpectedReturnInDescribe: 'Unexpected return statement in describe callback' 39 | }, 40 | schema: [] 41 | }, 42 | defaultOptions: [], 43 | create(context) { 44 | return { 45 | CallExpression(node) { 46 | const vitestFnCall = parseVitestFnCall(node, context) 47 | 48 | if (vitestFnCall?.type !== 'describe') return 49 | 50 | if (node.arguments.length < 1) { 51 | return context.report({ 52 | messageId: 'nameAndCallback', 53 | loc: node.loc 54 | }) 55 | } 56 | 57 | const [, callback] = node.arguments 58 | 59 | if (!callback) { 60 | context.report({ 61 | messageId: 'nameAndCallback', 62 | loc: paramsLocation(node.arguments) 63 | }) 64 | return 65 | } 66 | 67 | if (!isFunction(callback)) { 68 | context.report({ 69 | messageId: 'secondArgumentMustBeFunction', 70 | loc: paramsLocation(node.arguments) 71 | }) 72 | return 73 | } 74 | 75 | if (callback.async) { 76 | context.report({ 77 | messageId: 'noAsyncDescribeCallback', 78 | node: callback 79 | }) 80 | } 81 | 82 | if (vitestFnCall.members.every(s => getAccessorValue(s) !== 'each') && 83 | callback.params.length) { 84 | context.report({ 85 | messageId: 'unexpectedDescribeArgument', 86 | node: callback 87 | }) 88 | } 89 | 90 | if (callback.body.type === AST_NODE_TYPES.CallExpression) { 91 | context.report({ 92 | messageId: 'unexpectedReturnInDescribe', 93 | node: callback 94 | }) 95 | } 96 | 97 | if (callback.body.type === AST_NODE_TYPES.BlockStatement) { 98 | callback.body.body.forEach(node => { 99 | if (node.type === AST_NODE_TYPES.ReturnStatement) { 100 | context.report({ 101 | messageId: 'unexpectedReturnInDescribe', 102 | node 103 | }) 104 | } 105 | }) 106 | } 107 | } 108 | } 109 | } 110 | }) 111 | -------------------------------------------------------------------------------- /src/rules/prefer-spy-on.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule, getNodeName } from '../utils' 3 | 4 | export const RULE_NAME = 'prefer-spy-on' 5 | type MESSAGE_IDS = 'useViSpayOn' 6 | type Options = [] 7 | 8 | const findNodeObject = ( 9 | node: TSESTree.CallExpression | TSESTree.MemberExpression 10 | ): TSESTree.Expression | null => { 11 | if ('object' in node) 12 | return node.object 13 | 14 | if (node.callee.type === AST_NODE_TYPES.MemberExpression) 15 | return node.callee.object 16 | 17 | return null 18 | } 19 | 20 | const getVitestFnCall = (node: TSESTree.Node): TSESTree.CallExpression | null => { 21 | if ( 22 | node.type !== AST_NODE_TYPES.CallExpression && 23 | node.type !== AST_NODE_TYPES.MemberExpression 24 | ) 25 | return null 26 | 27 | const obj = findNodeObject(node) 28 | 29 | if (!obj) 30 | return null 31 | 32 | if (obj.type === AST_NODE_TYPES.Identifier) { 33 | return node.type === AST_NODE_TYPES.CallExpression && 34 | getNodeName(node.callee) === 'vi.fn' 35 | ? node 36 | : null 37 | } 38 | 39 | return getVitestFnCall(obj) 40 | } 41 | 42 | const getAutoFixMockImplementation = ( 43 | vitestFnCall: TSESTree.CallExpression, 44 | context: TSESLint.RuleContext 45 | ): string => { 46 | const hasMockImplementationAlready = 47 | vitestFnCall.parent?.type === AST_NODE_TYPES.MemberExpression && 48 | vitestFnCall.parent.property.type === AST_NODE_TYPES.Identifier && 49 | vitestFnCall.parent.property.name === 'mockImplementation' 50 | 51 | if (hasMockImplementationAlready) 52 | return '' 53 | 54 | const [arg] = vitestFnCall.arguments 55 | const argSource = arg && context.getSourceCode().getText(arg) 56 | 57 | return argSource 58 | ? `.mockImplementation(${argSource})` 59 | : '.mockImplementation()' 60 | } 61 | 62 | export default createEslintRule({ 63 | name: RULE_NAME, 64 | meta: { 65 | type: 'suggestion', 66 | docs: { 67 | description: 'Suggest using `vi.spyOn`', 68 | recommended: 'warn' 69 | }, 70 | messages: { 71 | useViSpayOn: 'Use `vi.spyOn` instead' 72 | }, 73 | fixable: 'code', 74 | schema: [] 75 | }, 76 | defaultOptions: [], 77 | create(context) { 78 | return { 79 | AssignmentExpression(node) { 80 | const { left, right } = node 81 | 82 | if (left.type !== AST_NODE_TYPES.MemberExpression) return 83 | 84 | const vitestFnCall = getVitestFnCall(right) 85 | 86 | if (!vitestFnCall) return 87 | 88 | context.report({ 89 | node, 90 | messageId: 'useViSpayOn', 91 | fix(fixer) { 92 | const lefPropQuote = left.property.type === AST_NODE_TYPES.Identifier && !left.computed 93 | ? '\'' 94 | : '' 95 | 96 | const mockImplementation = getAutoFixMockImplementation(vitestFnCall, context) 97 | 98 | return [ 99 | fixer.insertTextBefore(left, 'vi.spyOn('), 100 | fixer.replaceTextRange( 101 | [left.object.range[1], left.property.range[0]], 102 | `, ${lefPropQuote}` 103 | ), 104 | fixer.replaceTextRange( 105 | [left.property.range[1], vitestFnCall.range[1]], 106 | `${lefPropQuote})${mockImplementation}` 107 | ) 108 | ] 109 | } 110 | }) 111 | } 112 | } 113 | } 114 | }) 115 | -------------------------------------------------------------------------------- /tests/no-conditional-expect.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-conditional-expect' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(`${RULE_NAME}-logical conditions`, rule, { 5 | valid: [ 6 | `it('foo', () => { 7 | process.env.FAIL && setNum(1); 8 | 9 | expect(num).toBe(2); 10 | });`, 11 | ` 12 | function getValue() { 13 | let num = 2; 14 | 15 | process.env.FAIL && setNum(1); 16 | 17 | return num; 18 | } 19 | 20 | it('foo', () => { 21 | expect(getValue()).toBe(2); 22 | }); 23 | `, 24 | ` 25 | function getValue() { 26 | let num = 2; 27 | 28 | process.env.FAIL || setNum(1); 29 | 30 | return num; 31 | } 32 | 33 | it('foo', () => { 34 | expect(getValue()).toBe(2); 35 | }); 36 | ` 37 | ], 38 | invalid: [ 39 | { 40 | code: ` it('foo', () => { 41 | something && expect(something).toHaveBeenCalled(); 42 | })`, 43 | errors: [ 44 | { 45 | messageId: 'noConditionalExpect' 46 | } 47 | ] 48 | }, 49 | { 50 | code: ` it('foo', () => { 51 | a || (b && expect(something).toHaveBeenCalled()); 52 | })`, 53 | errors: [ 54 | { 55 | messageId: 'noConditionalExpect' 56 | } 57 | ] 58 | }, 59 | { 60 | code: ` 61 | it.each\`\`('foo', () => { 62 | something || expect(something).toHaveBeenCalled(); 63 | }) 64 | `, 65 | errors: [{ messageId: 'noConditionalExpect' }] 66 | }, 67 | { 68 | code: ` 69 | it.each()('foo', () => { 70 | something || expect(something).toHaveBeenCalled(); 71 | }) 72 | `, 73 | errors: [{ messageId: 'noConditionalExpect' }] 74 | }, 75 | { 76 | code: ` 77 | function getValue() { 78 | something || expect(something).toHaveBeenCalled(); 79 | } 80 | 81 | it('foo', getValue); 82 | `, 83 | errors: [{ messageId: 'noConditionalExpect' }] 84 | } 85 | ] 86 | }) 87 | 88 | ruleTester.run(`${RULE_NAME}-conditional conditions`, rule, { 89 | valid: [ 90 | ` 91 | it('foo', () => { 92 | const num = process.env.FAIL ? 1 : 2; 93 | 94 | expect(num).toBe(2); 95 | }); 96 | `, 97 | ` 98 | function getValue() { 99 | return process.env.FAIL ? 1 : 2 100 | } 101 | 102 | it('foo', () => { 103 | expect(getValue()).toBe(2); 104 | }); 105 | ` 106 | ], 107 | invalid: [ 108 | { 109 | code: ` 110 | it('foo', () => { 111 | something ? expect(something).toHaveBeenCalled() : noop(); 112 | }) 113 | `, 114 | errors: [{ messageId: 'noConditionalExpect' }] 115 | }, 116 | { 117 | code: ` 118 | function getValue() { 119 | something ? expect(something).toHaveBeenCalled() : noop(); 120 | } 121 | 122 | it('foo', getValue); 123 | `, 124 | errors: [{ messageId: 'noConditionalExpect' }] 125 | }, 126 | { 127 | code: ` 128 | it('foo', () => { 129 | something ? noop() : expect(something).toHaveBeenCalled(); 130 | }) 131 | `, 132 | errors: [{ messageId: 'noConditionalExpect' }] 133 | } 134 | ] 135 | }) 136 | -------------------------------------------------------------------------------- /tests/no-standalone-expect.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-standalone-expect' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'expect.any(String)', 7 | 'expect.extend({})', 8 | 'describe("a test", () => { it("an it", () => {expect(1).toBe(1); }); });', 9 | 'describe("a test", () => { it("an it", () => { const func = () => { expect(1).toBe(1); }; }); });', 10 | 'describe("a test", () => { const func = () => { expect(1).toBe(1); }; });', 11 | 'describe("a test", () => { function func() { expect(1).toBe(1); }; });', 12 | 'describe("a test", () => { const func = function(){ expect(1).toBe(1); }; });', 13 | 'it("an it", () => expect(1).toBe(1))', 14 | 'const func = function(){ expect(1).toBe(1); };', 15 | 'const func = () => expect(1).toBe(1);', 16 | '{}', 17 | 'it.each([1, true])("trues", value => { expect(value).toBe(true); });', 18 | 'it.each([1, true])("trues", value => { expect(value).toBe(true); }); it("an it", () => { expect(1).toBe(1) });' 19 | ], 20 | invalid: [ 21 | { 22 | code: '(() => {})(\'testing\', () => expect(true).toBe(false))', 23 | errors: [{ endColumn: 53, column: 29, messageId: 'noStandaloneExpect' }] 24 | }, 25 | { 26 | code: 'expect.hasAssertions()', 27 | errors: [{ endColumn: 23, column: 1, messageId: 'noStandaloneExpect' }] 28 | }, 29 | { 30 | code: ` 31 | describe('scenario', () => { 32 | const t = Math.random() ? it.only : it; 33 | t('testing', () => expect(true).toBe(false)); 34 | }); 35 | `, 36 | errors: [{ endColumn: 50, column: 26, messageId: 'noStandaloneExpect' }] 37 | }, 38 | { 39 | code: `describe('scenario', () => { 40 | const t = Math.random() ? it.only : it; 41 | t('testing', () => expect(true).toBe(false)); 42 | });`, 43 | errors: [{ endColumn: 50, column: 26, messageId: 'noStandaloneExpect' }] 44 | }, 45 | { 46 | code: 'describe("a test", () => { expect(1).toBe(1); });', 47 | errors: [{ endColumn: 45, column: 28, messageId: 'noStandaloneExpect' }] 48 | }, 49 | { 50 | code: 'describe("a test", () => expect(1).toBe(1));', 51 | errors: [{ endColumn: 43, column: 26, messageId: 'noStandaloneExpect' }] 52 | }, 53 | { 54 | code: 'describe("a test", () => { const func = () => { expect(1).toBe(1); }; expect(1).toBe(1); });', 55 | errors: [{ endColumn: 88, column: 71, messageId: 'noStandaloneExpect' }] 56 | }, 57 | { 58 | code: 'describe("a test", () => { it(() => { expect(1).toBe(1); }); expect(1).toBe(1); });', 59 | errors: [{ endColumn: 80, column: 63, messageId: 'noStandaloneExpect' }] 60 | }, 61 | { 62 | code: 'expect(1).toBe(1);', 63 | errors: [{ endColumn: 18, column: 1, messageId: 'noStandaloneExpect' }] 64 | }, 65 | { 66 | code: '{expect(1).toBe(1)}', 67 | errors: [{ endColumn: 19, column: 2, messageId: 'noStandaloneExpect' }] 68 | }, 69 | { 70 | code: ` 71 | each([ 72 | [1, 1, 2], 73 | [1, 2, 3], 74 | [2, 1, 3], 75 | ]).test('returns the result of adding %d to %d', (a, b, expected) => { 76 | expect(a + b).toBe(expected); 77 | });`, 78 | options: [{ additionalTestBlockFunctions: ['test'] }], 79 | errors: [{ endColumn: 36, column: 8, messageId: 'noStandaloneExpect' }] 80 | } 81 | ] 82 | }) 83 | -------------------------------------------------------------------------------- /docs/rules/no-hooks.md: -------------------------------------------------------------------------------- 1 | # Disallow setup and teardown hooks (`vitest/no-hooks`) 2 | 3 | ⚠️ This rule _warns_ in the 🌐 `all` config. 4 | 5 | 6 | 7 | ## Rule details 8 | 9 | This rule reports for the following function calls: 10 | 11 | - `beforeAll` 12 | - `beforeEach` 13 | - `afterAll` 14 | - `afterEach` 15 | 16 | Examples of **incorrect** code for this rule: 17 | 18 | ```js 19 | /* eslint vitest/no-hooks: "error" */ 20 | 21 | function setupFoo(options) { 22 | /* ... */ 23 | } 24 | 25 | function setupBar(options) { 26 | /* ... */ 27 | } 28 | 29 | describe('foo', () => { 30 | let foo; 31 | 32 | beforeEach(() => { 33 | foo = setupFoo(); 34 | }); 35 | 36 | afterEach(() => { 37 | foo = null; 38 | }); 39 | 40 | it('does something', () => { 41 | expect(foo.doesSomething()).toBe(true); 42 | }); 43 | 44 | describe('with bar', () => { 45 | let bar; 46 | 47 | beforeEach(() => { 48 | bar = setupBar(); 49 | }); 50 | 51 | afterEach(() => { 52 | bar = null; 53 | }); 54 | 55 | it('does something with bar', () => { 56 | expect(foo.doesSomething(bar)).toBe(true); 57 | }); 58 | }); 59 | }); 60 | ``` 61 | 62 | Examples of **correct** code for this rule: 63 | 64 | ```js 65 | /* eslint vitest/no-hooks: "error" */ 66 | 67 | function setupFoo(options) { 68 | /* ... */ 69 | } 70 | 71 | function setupBar(options) { 72 | /* ... */ 73 | } 74 | 75 | describe('foo', () => { 76 | it('does something', () => { 77 | const foo = setupFoo(); 78 | expect(foo.doesSomething()).toBe(true); 79 | }); 80 | 81 | it('does something with bar', () => { 82 | const foo = setupFoo(); 83 | const bar = setupBar(); 84 | expect(foo.doesSomething(bar)).toBe(true); 85 | }); 86 | }); 87 | ``` 88 | 89 | ## Options 90 | 91 | ```json 92 | { 93 | "vitest/no-hooks": [ 94 | "error", 95 | { 96 | "allow": ["afterEach", "afterAll"] 97 | } 98 | ] 99 | } 100 | ``` 101 | 102 | ### `allow` 103 | 104 | This array option controls which Vitest hooks are checked by this rule. There are 105 | four possible values: 106 | 107 | - `"beforeAll"` 108 | - `"beforeEach"` 109 | - `"afterAll"` 110 | - `"afterEach"` 111 | 112 | By default, none of these options are enabled (the equivalent of 113 | `{ "allow": [] }`). 114 | 115 | Examples of **incorrect** code for the `{ "allow": ["afterEach"] }` option: 116 | 117 | ```js 118 | /* eslint vitest/no-hooks: ["error", { "allow": ["afterEach"] }] */ 119 | 120 | function setupFoo(options) { 121 | /* ... */ 122 | } 123 | 124 | let foo; 125 | 126 | beforeEach(() => { 127 | foo = setupFoo(); 128 | }); 129 | 130 | afterEach(() => { 131 | vi.resetModules(); 132 | }); 133 | 134 | test('foo does this', () => { 135 | // ... 136 | }); 137 | 138 | test('foo does that', () => { 139 | // ... 140 | }); 141 | ``` 142 | 143 | Examples of **correct** code for the `{ "allow": ["afterEach"] }` option: 144 | 145 | ```js 146 | /* eslint vitest/no-hooks: ["error", { "allow": ["afterEach"] }] */ 147 | 148 | function setupFoo(options) { 149 | /* ... */ 150 | } 151 | 152 | afterEach(() => { 153 | vi.resetModules(); 154 | }); 155 | 156 | test('foo does this', () => { 157 | const foo = setupFoo(); 158 | // ... 159 | }); 160 | 161 | test('foo does that', () => { 162 | const foo = setupFoo(); 163 | // ... 164 | }); 165 | ``` -------------------------------------------------------------------------------- /src/rules/prefer-snapshot-hint.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule, getAccessorValue, isStringNode, isSupportedAccessor } from '../utils' 2 | import { isTypeOfVitestFnCall, ParsedExpectVitestFnCall, parseVitestFnCall } from '../utils/parseVitestFnCall' 3 | 4 | export const RULE_NAME = 'prefer-snapshot-hint' 5 | type MESSAGE_IDS = 'missingHint' 6 | type Options = [ 7 | ('always' | 'multi')? 8 | ] 9 | 10 | const snapshotMatchers = ['toMatchSnapshot', 'toThrowErrorMatchingSnapshot'] 11 | const snapshotMatcherNames = snapshotMatchers 12 | 13 | const isSnapshotMatcherWithoutHint = (expectFnCall: ParsedExpectVitestFnCall) => { 14 | if (expectFnCall.args.length === 0) return true 15 | 16 | if (!isSupportedAccessor(expectFnCall.matcher, 'toMatchSnapshot')) 17 | return expectFnCall.args.length !== 1 18 | 19 | if (expectFnCall.args.length === 2) return false 20 | 21 | const [arg] = expectFnCall.args 22 | 23 | return !isStringNode(arg) 24 | } 25 | 26 | export default createEslintRule({ 27 | name: RULE_NAME, 28 | meta: { 29 | type: 'suggestion', 30 | docs: { 31 | description: 'Prefer including a hint with external snapshots', 32 | recommended: 'warn' 33 | }, 34 | messages: { 35 | missingHint: 'You should provide a hint for this snapshot' 36 | }, 37 | schema: [ 38 | { 39 | type: 'string', 40 | enum: ['always', 'multi'] 41 | } 42 | ] 43 | }, 44 | defaultOptions: ['multi'], 45 | create(context, [mode]) { 46 | const snapshotMatchers: ParsedExpectVitestFnCall[] = [] 47 | let expressionDepth = 0 48 | const depths: number[] = [] 49 | 50 | const reportSnapshotMatchersWithoutHints = () => { 51 | for (const snapshotMatcher of snapshotMatchers) { 52 | if (isSnapshotMatcherWithoutHint(snapshotMatcher)) { 53 | context.report({ 54 | messageId: 'missingHint', 55 | node: snapshotMatcher.matcher 56 | }) 57 | } 58 | } 59 | } 60 | 61 | const enterExpression = () => { 62 | expressionDepth++ 63 | } 64 | 65 | const exitExpression = () => { 66 | expressionDepth-- 67 | 68 | if (mode === 'always') { 69 | reportSnapshotMatchersWithoutHints() 70 | snapshotMatchers.length = 0 71 | } 72 | 73 | if (mode === 'multi' && expressionDepth === 0) { 74 | if (snapshotMatchers.length > 1) 75 | reportSnapshotMatchersWithoutHints() 76 | 77 | snapshotMatchers.length = 0 78 | } 79 | } 80 | 81 | return { 82 | 'Program:exit'() { 83 | enterExpression() 84 | exitExpression() 85 | }, 86 | FunctionExpression: enterExpression, 87 | 'FunctionExpression:exit': exitExpression, 88 | ArrowFunctionExpression: enterExpression, 89 | 'ArrowFunctionExpression:exit': exitExpression, 90 | 'CallExpression:exit'(node) { 91 | if (isTypeOfVitestFnCall(node, context, ['describe', 'test'])) 92 | expressionDepth = depths.pop() ?? 0 93 | }, 94 | CallExpression(node) { 95 | const vitestFnCall = parseVitestFnCall(node, context) 96 | 97 | if (vitestFnCall?.type !== 'expect') { 98 | if (vitestFnCall?.type === 'describe' || vitestFnCall?.type === 'test') { 99 | depths.push(expressionDepth) 100 | expressionDepth = 0 101 | } 102 | return 103 | } 104 | 105 | const matcherName = getAccessorValue(vitestFnCall.matcher) 106 | 107 | if (!snapshotMatcherNames.includes(matcherName)) return 108 | 109 | snapshotMatchers.push(vitestFnCall) 110 | } 111 | } 112 | } 113 | }) 114 | -------------------------------------------------------------------------------- /tests/no-done-callback.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../src/rules/no-done-callback' 2 | import { ruleTester } from './ruleTester' 3 | 4 | ruleTester.run(RULE_NAME, rule, { 5 | valid: [ 6 | 'test("something", () => {})', 7 | 'test("something", async () => {})', 8 | 'test("something", function() {})', 9 | 'test.each``("something", ({ a, b }) => {})', 10 | 'test.each()("something", ({ a, b }) => {})', 11 | 'it.each()("something", ({ a, b }) => {})', 12 | 'it.each([])("something", (a, b) => {})', 13 | 'it.each``("something", ({ a, b }) => {})', 14 | 'it.each([])("something", (a, b) => { a(); b(); })', 15 | 'it.each``("something", ({ a, b }) => { a(); b(); })', 16 | 'test("something", async function () {})', 17 | 'test("something", someArg)', 18 | 'beforeEach(() => {})', 19 | 'beforeAll(async () => {})', 20 | 'afterAll(() => {})', 21 | 'afterAll(async function () {})', 22 | 'afterAll(async function () {}, 5)' 23 | ], 24 | invalid: [ 25 | { 26 | code: 'test("something", (...args) => {args[0]();})', 27 | errors: [{ messageId: 'noDoneCallback', line: 1, column: 20 }] 28 | }, 29 | { 30 | code: 'test("something", done => {done();})', 31 | errors: [ 32 | { 33 | messageId: 'noDoneCallback', 34 | line: 1, 35 | column: 1, 36 | suggestions: [ 37 | { 38 | messageId: 'suggestWrappingInPromise', 39 | data: { callback: 'done' }, 40 | output: 41 | 'test("something", () => {return new Promise(done => {done();})})' 42 | } 43 | ] 44 | } 45 | ] 46 | }, 47 | { 48 | code: 'test("something", finished => {finished();})', 49 | errors: [ 50 | { 51 | messageId: 'noDoneCallback', 52 | line: 1, 53 | column: 1, 54 | suggestions: [ 55 | { 56 | messageId: 'suggestWrappingInPromise', 57 | data: { callback: 'finished' }, 58 | output: 59 | 'test("something", () => {return new Promise(finished => {finished();})})' 60 | } 61 | ] 62 | } 63 | ] 64 | }, 65 | { 66 | code: 'beforeAll(async done => {done();})', 67 | errors: [{ messageId: 'useAwaitInsteadOfCallback', line: 1, column: 17 }] 68 | }, 69 | { 70 | code: 'beforeEach((done) => {done();});', 71 | errors: [ 72 | { 73 | messageId: 'noDoneCallback', 74 | line: 1, 75 | column: 1, 76 | suggestions: [ 77 | { 78 | messageId: 'suggestWrappingInPromise', 79 | data: { callback: 'done' }, 80 | output: 81 | 'beforeEach(() => {return new Promise(done => {done();})});' 82 | } 83 | ] 84 | } 85 | ] 86 | }, 87 | { 88 | code: 'test.each``("something", ({ a, b }, done) => { done(); })', 89 | errors: [ 90 | { 91 | messageId: 'noDoneCallback', 92 | line: 1, 93 | column: 1 94 | } 95 | ] 96 | }, 97 | { 98 | code: 'it.each``("something", ({ a, b }, done) => { done(); })', 99 | errors: [ 100 | { 101 | messageId: 'noDoneCallback', 102 | line: 1, 103 | column: 1 104 | } 105 | ] 106 | } 107 | ] 108 | }) 109 | --------------------------------------------------------------------------------