├── .codecov.yml ├── .eslint-doc-generatorrc.js ├── .eslintignore ├── .eslintrc.js ├── .github ├── renovate.json └── workflows │ ├── lint.yml │ ├── markdown-link-check.yml │ ├── nodejs.yml │ ├── prepare-cache.yml │ ├── smoke-test.yml │ └── test.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmignore ├── .prettierignore ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-interactive-tools.cjs └── releases │ └── yarn-3.8.7.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── dangerfile.ts ├── docs └── rules │ ├── prefer-to-be-array.md │ ├── prefer-to-be-false.md │ ├── prefer-to-be-object.md │ ├── prefer-to-be-true.md │ └── prefer-to-have-been-called-once.md ├── eslint-remote-tester.config.ts ├── jest.config.ts ├── markdown_link_check_config.json ├── package.json ├── src ├── __tests__ │ ├── __snapshots__ │ │ └── rules.test.ts.snap │ └── rules.test.ts ├── index.ts └── rules │ ├── __tests__ │ ├── prefer-to-be-array.test.ts │ ├── prefer-to-be-false.test.ts │ ├── prefer-to-be-object.test.ts │ ├── prefer-to-be-true.test.ts │ ├── prefer-to-have-been-called-once.test.ts │ └── test-utils.ts │ ├── prefer-to-be-array.ts │ ├── prefer-to-be-false.ts │ ├── prefer-to-be-object.ts │ ├── prefer-to-be-true.ts │ ├── prefer-to-have-been-called-once.ts │ └── utils │ ├── __tests__ │ └── parseJestFnCall.test.ts │ ├── accessors.ts │ ├── followTypeAssertionChain.ts │ ├── index.ts │ ├── misc.ts │ └── parseJestFnCall.ts ├── tsconfig.json └── yarn.lock /.codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | 3 | coverage: 4 | status: 5 | patch: 6 | default: 7 | target: 100% 8 | project: 9 | default: 10 | target: 100% 11 | -------------------------------------------------------------------------------- /.eslint-doc-generatorrc.js: -------------------------------------------------------------------------------- 1 | const { format } = require('prettier'); 2 | const { prettier: prettierRC } = require('./package.json'); 3 | 4 | /** @type {import('eslint-doc-generator').GenerateOptions} */ 5 | const config = { 6 | ignoreConfig: [ 7 | 'all', 8 | 'flat/all', 9 | 'flat/recommended', 10 | 'flat/style', 11 | 'flat/snapshots', 12 | ], 13 | ruleDocTitleFormat: 'desc-parens-name', 14 | ruleDocSectionInclude: ['Rule details'], 15 | ruleListColumns: [ 16 | 'name', 17 | 'description', 18 | 'configsError', 19 | 'configsWarn', 20 | 'configsOff', 21 | 'fixable', 22 | 'hasSuggestions', 23 | 'deprecated', 24 | ], 25 | urlConfigs: `https://github.com/jest-community/eslint-plugin-jest-extended/blob/main/README.md#shareable-configurations`, 26 | postprocess: doc => format(doc, { ...prettierRC, parser: 'markdown' }), 27 | }; 28 | 29 | module.exports = config; 30 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | lib/ 3 | !.eslintrc.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { 4 | version: typescriptESLintPluginVersion, 5 | } = require('@typescript-eslint/eslint-plugin/package.json'); 6 | const semver = require('semver'); 7 | 8 | const typescriptBanTypesRules = () => { 9 | if (semver.major(typescriptESLintPluginVersion) === 8) { 10 | return { 11 | '@typescript-eslint/no-empty-object-type': 'error', 12 | '@typescript-eslint/no-unsafe-function-type': 'error', 13 | '@typescript-eslint/no-wrapper-object-types': 'error', 14 | }; 15 | } 16 | 17 | return { 18 | '@typescript-eslint/ban-types': 'error', 19 | }; 20 | }; 21 | 22 | module.exports = { 23 | parser: require.resolve('@typescript-eslint/parser'), 24 | extends: [ 25 | 'plugin:eslint-plugin/recommended', 26 | 'plugin:@eslint-community/eslint-comments/recommended', 27 | 'plugin:n/recommended', 28 | 'plugin:@typescript-eslint/eslint-recommended', 29 | 'plugin:prettier/recommended', 30 | ], 31 | plugins: [ 32 | 'eslint-plugin', 33 | '@eslint-community/eslint-comments', 34 | 'n', 35 | 'import', 36 | '@typescript-eslint', 37 | ], 38 | parserOptions: { 39 | ecmaVersion: 2018, 40 | warnOnUnsupportedTypeScriptVersion: false, 41 | }, 42 | env: { 43 | node: true, 44 | es6: true, 45 | }, 46 | rules: { 47 | '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], 48 | '@typescript-eslint/no-require-imports': 'error', 49 | '@typescript-eslint/ban-ts-comment': 'error', 50 | ...typescriptBanTypesRules(), 51 | '@typescript-eslint/consistent-type-imports': [ 52 | 'error', 53 | { disallowTypeAnnotations: false, fixStyle: 'inline-type-imports' }, 54 | ], 55 | '@typescript-eslint/no-import-type-side-effects': 'error', 56 | '@typescript-eslint/no-unused-vars': 'error', 57 | '@eslint-community/eslint-comments/no-unused-disable': 'error', 58 | 'eslint-plugin/require-meta-docs-description': [ 59 | 'error', 60 | { pattern: '^(Enforce|Require|Disallow|Suggest|Prefer)' }, 61 | ], 62 | 'eslint-plugin/test-case-property-ordering': 'error', 63 | 'no-else-return': 'error', 64 | 'no-negated-condition': 'error', 65 | eqeqeq: ['error', 'smart'], 66 | strict: 'error', 67 | 'prefer-template': 'error', 68 | 'object-shorthand': [ 69 | 'error', 70 | 'always', 71 | { avoidExplicitReturnArrows: true }, 72 | ], 73 | 'prefer-destructuring': [ 74 | 'error', 75 | { VariableDeclarator: { array: true, object: true } }, 76 | ], 77 | 'sort-imports': ['error', { ignoreDeclarationSort: true }], 78 | 'require-unicode-regexp': 'error', 79 | // TS covers these 2 80 | 'n/no-missing-import': 'off', 81 | 'n/no-missing-require': 'off', 82 | 'n/no-unsupported-features/es-syntax': 'off', 83 | 'n/no-unsupported-features/es-builtins': 'error', 84 | 'import/no-commonjs': 'error', 85 | 'import/no-duplicates': 'error', 86 | 'import/no-extraneous-dependencies': 'error', 87 | 'import/no-unused-modules': 'error', 88 | 'import/order': [ 89 | 'error', 90 | { alphabetize: { order: 'asc' }, 'newlines-between': 'never' }, 91 | ], 92 | 'padding-line-between-statements': [ 93 | 'error', 94 | { blankLine: 'always', prev: '*', next: 'return' }, 95 | { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' }, 96 | { 97 | blankLine: 'any', 98 | prev: ['const', 'let', 'var'], 99 | next: ['const', 'let', 'var'], 100 | }, 101 | { blankLine: 'always', prev: 'directive', next: '*' }, 102 | { blankLine: 'any', prev: 'directive', next: 'directive' }, 103 | ], 104 | 105 | 'prefer-spread': 'error', 106 | 'prefer-rest-params': 'error', 107 | 'prefer-const': ['error', { destructuring: 'all' }], 108 | 'no-var': 'error', 109 | curly: 'error', 110 | }, 111 | overrides: [ 112 | { 113 | files: ['*.js'], 114 | rules: { 115 | '@typescript-eslint/no-require-imports': 'off', 116 | }, 117 | }, 118 | { 119 | files: ['src/**/*', 'dangerfile.ts', './jest.config.ts'], 120 | parserOptions: { 121 | sourceType: 'module', 122 | }, 123 | }, 124 | { 125 | files: ['tools/*'], 126 | rules: { 127 | 'n/shebang': 'off', 128 | }, 129 | }, 130 | { 131 | files: ['.eslintrc.js', 'babel.config.js'], 132 | rules: { 133 | '@typescript-eslint/no-require-imports': 'off', 134 | 'import/no-commonjs': 'off', 135 | }, 136 | }, 137 | ], 138 | }; 139 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "lockFileMaintenance": { "enabled": true, "automerge": true }, 4 | "rangeStrategy": "replace", 5 | "postUpdateOptions": ["yarnDedupeHighest"] 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read # to fetch code (actions/checkout) 6 | 7 | jobs: 8 | commitlint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | persist-credentials: false 15 | - uses: wagoid/commitlint-github-action@v6 16 | with: 17 | configFile: './package.json' 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | danger: 21 | permissions: 22 | contents: read # to fetch code (actions/checkout) 23 | issues: write # to create comment 24 | pull-requests: write # to create comment 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | persist-credentials: false 30 | - name: Danger 31 | uses: danger/danger-js@12.3.4 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/markdown-link-check.yml: -------------------------------------------------------------------------------- 1 | name: Check Markdown links 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 12 * * *' 7 | pull_request: 8 | paths: 9 | - '**/*.md' 10 | - 'markdown_link_check_config.json' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | prepare-yarn-cache-ubuntu: 17 | uses: ./.github/workflows/prepare-cache.yml 18 | with: 19 | os: ubuntu-latest 20 | 21 | markdown-link-check: 22 | needs: prepare-yarn-cache-ubuntu 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | persist-credentials: false 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: lts/* 31 | cache: yarn 32 | - name: install 33 | run: yarn 34 | - name: Run markdown-link-check on MD files 35 | run: 36 | find . -name "*.md" | grep -v node_modules | grep -v CHANGELOG.md | 37 | xargs -n 1 yarn markdown-link-check -c markdown_link_check_config.json 38 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests & Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | pull_request: 9 | branches: 10 | - main 11 | - next 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 15 | cancel-in-progress: true 16 | 17 | permissions: 18 | contents: read # to fetch code (actions/checkout) 19 | 20 | jobs: 21 | prepare-yarn-cache-ubuntu: 22 | uses: ./.github/workflows/prepare-cache.yml 23 | with: 24 | os: ubuntu-latest 25 | prepare-yarn-cache-macos: 26 | uses: ./.github/workflows/prepare-cache.yml 27 | with: 28 | os: macos-latest 29 | prepare-yarn-cache-windows: 30 | uses: ./.github/workflows/prepare-cache.yml 31 | with: 32 | os: windows-latest 33 | 34 | prettier: 35 | needs: prepare-yarn-cache-ubuntu 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | persist-credentials: false 41 | - uses: actions/setup-node@v4 42 | with: 43 | node-version: lts/* 44 | cache: yarn 45 | - name: install 46 | run: yarn 47 | - name: run prettier 48 | run: yarn prettier:check 49 | 50 | typecheck: 51 | needs: prepare-yarn-cache-ubuntu 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v4 55 | with: 56 | persist-credentials: false 57 | - uses: actions/setup-node@v4 58 | with: 59 | node-version: lts/* 60 | cache: yarn 61 | - name: install 62 | run: yarn 63 | - name: run typecheck 64 | run: yarn typecheck 65 | 66 | test-node: 67 | name: 68 | # prettier-ignore 69 | Test on Node.js v${{ matrix.node-version }}, eslint v${{ matrix.eslint-version }}, and ts-eslint v${{ matrix.ts-eslint-plugin-version }} 70 | needs: prepare-yarn-cache-ubuntu 71 | strategy: 72 | fail-fast: false 73 | matrix: 74 | node-version: [16.x, 18.x, 19.x, 20.x, 21.x] 75 | eslint-version: [7, 8, 9] 76 | ts-eslint-plugin-version: [6, 7, 8] 77 | exclude: 78 | # ts-eslint/plugin@7 doesn't support node@16 79 | - node-version: 16.x 80 | ts-eslint-plugin-version: 7 81 | # ts-eslint/plugin@8 doesn't support node@16 82 | - node-version: 16.x 83 | ts-eslint-plugin-version: 8 84 | # eslint@9 doesn't support node@16 85 | - node-version: 16.x 86 | eslint-version: 9 87 | # ts-eslint/plugin@7 doesn't support eslint@7 88 | - eslint-version: 7 89 | ts-eslint-plugin-version: 7 90 | # ts-eslint/plugin@8 doesn't support eslint@7 91 | - eslint-version: 7 92 | ts-eslint-plugin-version: 8 93 | runs-on: ubuntu-latest 94 | 95 | steps: 96 | - uses: actions/checkout@v4 97 | with: 98 | persist-credentials: false 99 | - name: Use Node.js ${{ matrix.node-version }} 100 | uses: actions/setup-node@v4 101 | with: 102 | node-version: ${{ matrix.node-version }} 103 | cache: yarn 104 | - name: 105 | # prettier-ignore 106 | install with eslint v${{ matrix.eslint-version }} 107 | run: | 108 | yarn 109 | yarn add @typescript-eslint/utils@${{ matrix.ts-eslint-plugin-version }} 110 | # prettier-ignore 111 | yarn add --dev eslint@${{ matrix.eslint-version }} @typescript-eslint/eslint-plugin@${{ matrix.ts-eslint-plugin-version }} @typescript-eslint/parser@${{ matrix.ts-eslint-plugin-version }} 112 | - name: run tests 113 | # only collect coverage on eslint versions that support dynamic import 114 | run: yarn test --coverage ${{ matrix.eslint-version == 8 }} 115 | env: 116 | CI: true 117 | - uses: codecov/codecov-action@v3 118 | if: ${{ matrix.eslint-version == 8 }} 119 | test-ubuntu: 120 | uses: ./.github/workflows/test.yml 121 | needs: prepare-yarn-cache-ubuntu 122 | with: 123 | os: ubuntu-latest 124 | test-macos: 125 | uses: ./.github/workflows/test.yml 126 | needs: prepare-yarn-cache-macos 127 | with: 128 | os: macos-latest 129 | test-windows: 130 | uses: ./.github/workflows/test.yml 131 | needs: prepare-yarn-cache-windows 132 | with: 133 | os: windows-latest 134 | 135 | docs: 136 | if: ${{ github.event_name == 'pull_request' }} 137 | needs: prepare-yarn-cache-ubuntu 138 | runs-on: ubuntu-latest 139 | steps: 140 | - uses: actions/checkout@v4 141 | - uses: actions/setup-node@v4 142 | with: 143 | node-version: lts/* 144 | cache: yarn 145 | - name: install 146 | run: yarn 147 | - name: regenerate docs 148 | run: yarn tools:regenerate-docs 149 | - name: report regenerated docs 150 | run: | 151 | git diff --name-only \ 152 | | xargs -I '{}' bash -c \ 153 | 'echo "::error file={}::This needs to be regenerated by running \`tools:regenerate-docs\`" && false' 154 | 155 | release: 156 | permissions: 157 | contents: write # for semantic-release 158 | issues: write # to create comment 159 | pull-requests: write # to create comment 160 | 161 | if: 162 | # prettier-ignore 163 | ${{ github.event_name == 'push' && (github.event.ref == 'refs/heads/main' || github.event.ref == 'refs/heads/next') }} 164 | name: Release new version 165 | needs: 166 | [prettier, typecheck, test-node, test-ubuntu, test-macos, test-windows] 167 | runs-on: ubuntu-latest 168 | steps: 169 | - uses: actions/checkout@v4 170 | with: 171 | persist-credentials: false 172 | - uses: actions/setup-node@v4 173 | with: 174 | node-version: lts/* 175 | cache: yarn 176 | - name: install 177 | run: yarn 178 | - run: yarn semantic-release 179 | env: 180 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 181 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 182 | -------------------------------------------------------------------------------- /.github/workflows/prepare-cache.yml: -------------------------------------------------------------------------------- 1 | name: Prepare CI cache 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | os: 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | prepare-yarn-cache: 12 | name: Prepare yarn cache for ${{ inputs.os }} 13 | runs-on: ${{ inputs.os }} 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | persist-credentials: false 19 | 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: lts/* 23 | cache: yarn 24 | 25 | - name: Validate cache 26 | env: 27 | # Use PnP and disable postinstall scripts as this just needs to 28 | # populate the cache for the other jobs 29 | YARN_NODE_LINKER: pnp 30 | YARN_ENABLE_SCRIPTS: false 31 | run: yarn --immutable 32 | -------------------------------------------------------------------------------- /.github/workflows/smoke-test.yml: -------------------------------------------------------------------------------- 1 | name: Smoke test 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * SUN' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read # to fetch code (actions/checkout) 10 | 11 | jobs: 12 | test: 13 | permissions: 14 | contents: read # to fetch code (actions/checkout) 15 | issues: write # to create comment 16 | pull-requests: read # for searching pull requests 17 | 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | persist-credentials: false 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: 16 26 | - run: | 27 | npm install --legacy-peer-deps 28 | npm run build 29 | npm link 30 | npm link eslint-plugin-jest-extended 31 | - uses: AriPerkkio/eslint-remote-tester-run-action@v4 32 | with: 33 | issue-title: 'Results of weekly scheduled smoke test' 34 | eslint-remote-tester-config: eslint-remote-tester.config.ts 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | os: 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | test: 12 | name: Test on ${{ inputs.os }} using Node.js LTS 13 | runs-on: ${{ inputs.os }} 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | persist-credentials: false 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | cache: yarn 23 | - name: install 24 | run: yarn 25 | - name: run tests 26 | run: yarn test 27 | env: 28 | CI: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | coverage/ 4 | lib/ 5 | *.log 6 | .yarn/* 7 | !.yarn/releases 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/sdks 11 | !.yarn/versions 12 | .pnp.* 13 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | yarn commitlint --edit $1 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | yarn lint-staged 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/__tests__/** 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .yarn/* 2 | lib/* 3 | coverage/* 4 | CHANGELOG.md 5 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableGlobalCache: true 2 | 3 | nodeLinker: node-modules 4 | 5 | plugins: 6 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 7 | spec: '@yarnpkg/plugin-interactive-tools' 8 | 9 | yarnPath: .yarn/releases/yarn-3.8.7.cjs 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [3.0.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.4.0...v3.0.0) (2025-01-17) 2 | 3 | 4 | ### Features 5 | 6 | * adjust Node constraints to match `eslint-plugin-jest` ([#235](https://github.com/jest-community/eslint-plugin-jest-extended/issues/235)) ([6d2414a](https://github.com/jest-community/eslint-plugin-jest-extended/commit/6d2414a5bbb16988e7b3e0e88eca66ae0dab76c9)) 7 | * drop support for Node v14 ([#232](https://github.com/jest-community/eslint-plugin-jest-extended/issues/232)) ([fef65e0](https://github.com/jest-community/eslint-plugin-jest-extended/commit/fef65e0fb61adca91598bbf4f3018273fb7b752e)) 8 | * support `@typescript-eslint/utils` v7 & v8 ([#241](https://github.com/jest-community/eslint-plugin-jest-extended/issues/241)) ([40766b0](https://github.com/jest-community/eslint-plugin-jest-extended/commit/40766b019eb70271837e5eeb45d44dd21a999ca6)) 9 | * upgrade `@typescript-eslint/utils` to v6 ([#238](https://github.com/jest-community/eslint-plugin-jest-extended/issues/238)) ([657bc42](https://github.com/jest-community/eslint-plugin-jest-extended/commit/657bc428599e4b5794603c9b308dedb5029bf2c2)) 10 | 11 | 12 | ### BREAKING CHANGES 13 | 14 | * Versions of Node v18 up to 18.11.x are no longer supported 15 | * drop support for Node v14 16 | 17 | # [3.0.0-next.4](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v3.0.0-next.3...v3.0.0-next.4) (2025-01-17) 18 | 19 | 20 | ### Features 21 | 22 | * support `@typescript-eslint/utils` v7 & v8 ([#241](https://github.com/jest-community/eslint-plugin-jest-extended/issues/241)) ([414dc8f](https://github.com/jest-community/eslint-plugin-jest-extended/commit/414dc8f778d761c6f0f3eb3b621406cbe74b1afa)) 23 | 24 | # [3.0.0-next.3](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v3.0.0-next.2...v3.0.0-next.3) (2025-01-16) 25 | 26 | 27 | ### Features 28 | 29 | * upgrade `@typescript-eslint/utils` to v6 ([#238](https://github.com/jest-community/eslint-plugin-jest-extended/issues/238)) ([3815685](https://github.com/jest-community/eslint-plugin-jest-extended/commit/381568546db6b94fc2a92386556b737f1551e262)) 30 | 31 | # [3.0.0-next.2](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v3.0.0-next.1...v3.0.0-next.2) (2025-01-16) 32 | 33 | 34 | ### Features 35 | 36 | * adjust Node constraints to match `eslint-plugin-jest` ([#235](https://github.com/jest-community/eslint-plugin-jest-extended/issues/235)) ([c9fb39c](https://github.com/jest-community/eslint-plugin-jest-extended/commit/c9fb39caa43e074e64fb9fdb4dbcbdb2822fc57a)) 37 | 38 | 39 | ### BREAKING CHANGES 40 | 41 | * Versions of Node v18 up to 18.11.x are no longer supported 42 | 43 | # [3.0.0-next.1](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.4.0...v3.0.0-next.1) (2025-01-16) 44 | 45 | 46 | ### Features 47 | 48 | * drop support for Node v14 ([#232](https://github.com/jest-community/eslint-plugin-jest-extended/issues/232)) ([a3e953f](https://github.com/jest-community/eslint-plugin-jest-extended/commit/a3e953f06572f03777148f903f3e6486034468eb)) 49 | 50 | 51 | ### BREAKING CHANGES 52 | 53 | * drop support for Node v14 54 | 55 | # [2.4.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.3.0...v2.4.0) (2024-04-20) 56 | 57 | 58 | ### Features 59 | 60 | * support ESLint v9 ([#185](https://github.com/jest-community/eslint-plugin-jest-extended/issues/185)) ([42d36b1](https://github.com/jest-community/eslint-plugin-jest-extended/commit/42d36b198d0c6b489636843475e8ebd9ea4e837d)) 61 | 62 | # [2.3.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.2.0...v2.3.0) (2024-04-20) 63 | 64 | 65 | ### Features 66 | 67 | * add should-be-fine support for flat configs ([#181](https://github.com/jest-community/eslint-plugin-jest-extended/issues/181)) ([7d106b0](https://github.com/jest-community/eslint-plugin-jest-extended/commit/7d106b0fc8eb99946ba760bd9f4feccc8fb6e18e)) 68 | 69 | # [2.2.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.1.0...v2.2.0) (2024-04-19) 70 | 71 | 72 | ### Features 73 | 74 | * support providing aliases for `@jest/globals` package ([#180](https://github.com/jest-community/eslint-plugin-jest-extended/issues/180)) ([d070ca7](https://github.com/jest-community/eslint-plugin-jest-extended/commit/d070ca79caf41e0974c8b048a741f0db8104e4d1)) 75 | 76 | # [2.1.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.0.3...v2.1.0) (2024-04-19) 77 | 78 | 79 | ### Features 80 | 81 | * support `failing.each` ([#179](https://github.com/jest-community/eslint-plugin-jest-extended/issues/179)) ([b2adda4](https://github.com/jest-community/eslint-plugin-jest-extended/commit/b2adda4cacf1616ce18bed4d655a8a5b533c6664)) 82 | 83 | ## [2.0.3](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.0.2...v2.0.3) (2024-04-19) 84 | 85 | 86 | ### Bug Fixes 87 | 88 | * replace use of deprecated methods ([#178](https://github.com/jest-community/eslint-plugin-jest-extended/issues/178)) ([31c01ea](https://github.com/jest-community/eslint-plugin-jest-extended/commit/31c01ea02f4b8dbdf7e103efbde5aa9bd03fbfb2)) 89 | 90 | ## [2.0.2](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.0.1...v2.0.2) (2024-04-19) 91 | 92 | 93 | ### Performance Improvements 94 | 95 | * use `Set` instead of iterating, and deduplicate a function ([#175](https://github.com/jest-community/eslint-plugin-jest-extended/issues/175)) ([d0652cd](https://github.com/jest-community/eslint-plugin-jest-extended/commit/d0652cdb82b692cdee0f2ef4616b96e89c6d4ddf)) 96 | 97 | ## [2.0.1](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.0.0...v2.0.1) (2024-04-19) 98 | 99 | 100 | ### Performance Improvements 101 | 102 | * don't collect more info than needed when resolving jest functions ([#172](https://github.com/jest-community/eslint-plugin-jest-extended/issues/172)) ([08e130c](https://github.com/jest-community/eslint-plugin-jest-extended/commit/08e130c79df9e81e6b4c9c0e0f8b52885ee20ada)) 103 | 104 | # [2.0.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v1.2.0...v2.0.0) (2022-08-25) 105 | 106 | 107 | ### Features 108 | 109 | * drop support for `eslint@6` ([#26](https://github.com/jest-community/eslint-plugin-jest-extended/issues/26)) ([d3d40f3](https://github.com/jest-community/eslint-plugin-jest-extended/commit/d3d40f30266b00bf7182adcd90f52f3ccd1859ba)) 110 | * drop support for Node versions 12 and 17 ([#25](https://github.com/jest-community/eslint-plugin-jest-extended/issues/25)) ([14c90ed](https://github.com/jest-community/eslint-plugin-jest-extended/commit/14c90edffc359db59b1492fa9a94e361b6959f28)) 111 | 112 | 113 | ### BREAKING CHANGES 114 | 115 | * Support for ESLint version 6 is removed 116 | * Node versions 12 and 17 are no longer supported 117 | 118 | # [1.2.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v1.1.0...v1.2.0) (2022-08-20) 119 | 120 | 121 | ### Features 122 | 123 | * switch to new scope-based jest fn call parser to support `@jest/globals` ([#20](https://github.com/jest-community/eslint-plugin-jest-extended/issues/20)) ([35ddfed](https://github.com/jest-community/eslint-plugin-jest-extended/commit/35ddfedd58de975ca2c235a5295dfa28aab11ac5)) 124 | 125 | # [1.1.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v1.0.0...v1.1.0) (2022-08-20) 126 | 127 | 128 | ### Features 129 | 130 | * create `prefer-to-have-been-called-once` rule ([#19](https://github.com/jest-community/eslint-plugin-jest-extended/issues/19)) ([12e6372](https://github.com/jest-community/eslint-plugin-jest-extended/commit/12e6372ec54df6d768254bd528a970163a9fbc63)) 131 | 132 | # 1.0.0 (2022-08-15) 133 | 134 | 135 | ### Features 136 | 137 | * bunch of updates ([#5](https://github.com/jest-community/eslint-plugin-jest-extended/issues/5)) ([8e45c68](https://github.com/jest-community/eslint-plugin-jest-extended/commit/8e45c682b7c287f2180b03c4e903288a69c32711)) 138 | * create `prefer-to-be-array` rule ([9bd067c](https://github.com/jest-community/eslint-plugin-jest-extended/commit/9bd067ccc37d7651f812782bde785868c9cadfb4)) 139 | * create `prefer-to-be-false` rule ([b35e45c](https://github.com/jest-community/eslint-plugin-jest-extended/commit/b35e45c23cb2aa660034ac49b5082c61f34de758)) 140 | * create `prefer-to-be-object` rule ([676de1d](https://github.com/jest-community/eslint-plugin-jest-extended/commit/676de1d77ba19430d96f5df93ce8f3a548c6acfe)) 141 | * create `prefer-to-be-true` rule ([22f8093](https://github.com/jest-community/eslint-plugin-jest-extended/commit/22f8093136c30212498b8a347891f1cff995003b)) 142 | * initial commit ([3ed88c4](https://github.com/jest-community/eslint-plugin-jest-extended/commit/3ed88c4699d2ef82780183bae00560282a3f1916)) 143 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jonathan Kim 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 |

eslint-plugin-jest-extended

9 |

ESLint plugin for Jest Extended

10 |
11 | 12 | [![Actions Status](https://github.com/jest-community/eslint-plugin-jest-extended/workflows/Unit%20tests%20%26%20Release/badge.svg?branch=main)](https://github.com/jest-community/eslint-plugin-jest-extended/actions) 13 | 14 | ## Installation 15 | 16 | ``` 17 | $ yarn add --dev eslint eslint-plugin-jest-extended 18 | ``` 19 | 20 | **Note:** If you installed ESLint globally then you must also install 21 | `eslint-plugin-jest-extended` globally. 22 | 23 | ## Usage 24 | 25 | > [!NOTE] 26 | > 27 | > `eslint.config.js` is supported, though most of the plugin documentation still 28 | > currently uses `.eslintrc` syntax. 29 | > 30 | > Refer to the 31 | > [ESLint documentation on the new configuration file format](https://eslint.org/docs/latest/use/configure/configuration-files-new) 32 | > for more. 33 | 34 | Add `jest-extended` to the plugins section of your `.eslintrc` configuration 35 | file. You can omit the `eslint-plugin-` prefix: 36 | 37 | ```json 38 | { 39 | "plugins": ["jest-extended"] 40 | } 41 | ``` 42 | 43 | Then configure the rules you want to use under the rules section. 44 | 45 | ```json 46 | { 47 | "rules": { 48 | "jest-extended/prefer-to-be-true": "warn", 49 | "jest-extended/prefer-to-be-false": "error" 50 | } 51 | } 52 | ``` 53 | 54 | ## Shareable configurations 55 | 56 | ### Recommended 57 | 58 | This plugin does not export a recommended configuration, as the rules provided 59 | by this plugin are about enforcing usage of preferred matchers for particular 60 | patterns, rather than helping to prevent bugs & commonly overlooked traps. 61 | 62 | ### All 63 | 64 | If you want to enable all rules instead of only some you can do so by adding the 65 | `all` configuration to your `.eslintrc` config file: 66 | 67 | ```json 68 | { 69 | "extends": ["plugin:jest-extended/all"] 70 | } 71 | ``` 72 | 73 | To enable this configuration with `eslint.config.js`, use 74 | `jestExtended.configs['flat/all']`: 75 | 76 | ```js 77 | const jestExtended = require('eslint-plugin-jest-extended'); 78 | 79 | module.exports = [ 80 | { 81 | files: [ 82 | /* glob matching your test files */ 83 | ], 84 | ...jestExtended.configs['flat/all'], 85 | }, 86 | ]; 87 | ``` 88 | 89 | Note that the `all` configuration may change in any release and is thus unsuited 90 | for installations requiring long-term consistency. 91 | 92 | ## Rules 93 | 94 | 95 | 96 | 🔧 Automatically fixable by the 97 | [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). 98 | 99 | | Name | Description | 🔧 | 100 | | :------------------------------------------------------------------------------- | :------------------------------------- | :-- | 101 | | [prefer-to-be-array](docs/rules/prefer-to-be-array.md) | Suggest using `toBeArray()` | 🔧 | 102 | | [prefer-to-be-false](docs/rules/prefer-to-be-false.md) | Suggest using `toBeFalse()` | 🔧 | 103 | | [prefer-to-be-object](docs/rules/prefer-to-be-object.md) | Suggest using `toBeObject()` | 🔧 | 104 | | [prefer-to-be-true](docs/rules/prefer-to-be-true.md) | Suggest using `toBeTrue()` | 🔧 | 105 | | [prefer-to-have-been-called-once](docs/rules/prefer-to-have-been-called-once.md) | Suggest using `toHaveBeenCalledOnce()` | 🔧 | 106 | 107 | 108 | 109 | ## Credit 110 | 111 | - [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest) 112 | 113 | ## Related Projects 114 | 115 | ### eslint-plugin-jest 116 | 117 | This project aims to provide linting rules to aid in writing tests using jest. 118 | 119 | https://github.com/jest-community/eslint-plugin-jest 120 | 121 | ### eslint-plugin-jest-formatting 122 | 123 | This project aims to provide formatting rules (auto-fixable where possible) to 124 | ensure consistency and readability in jest test suites. 125 | 126 | https://github.com/dangreenisrael/eslint-plugin-jest-formatting 127 | 128 | [fixable]: https://img.shields.io/badge/-fixable-green.svg 129 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const semver = require('semver'); 4 | const pkg = require('./package.json'); 5 | 6 | const supportedNodeVersion = semver.minVersion(pkg.engines.node).version; 7 | 8 | // todo: https://github.com/babel/babel/issues/8529 :'( 9 | module.exports = { 10 | plugins: ['replace-ts-export-assignment'], 11 | presets: [ 12 | '@babel/preset-typescript', 13 | ['@babel/preset-env', { targets: { node: supportedNodeVersion } }], 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /dangerfile.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'path'; 2 | import { danger, warn } from 'danger'; 3 | 4 | // Ensure that people include a description on their PRs 5 | if (danger.github.pr.body.length === 0) { 6 | fail('Please include a body for your PR'); 7 | } 8 | 9 | const createOrAddLabelSafely = async (name: string, color: string): boolean => { 10 | try { 11 | await danger.github.utils.createOrAddLabel({ 12 | name, 13 | color: color.replace('#', ''), 14 | description: '', 15 | }); 16 | 17 | return true; 18 | } catch (error) { 19 | console.warn(error); 20 | warn(`Was unable to create or add label "${name}"`); 21 | 22 | return false; 23 | } 24 | }; 25 | 26 | const labelBasedOnRules = async () => { 27 | const affectedRules = [ 28 | ...danger.git.created_files, 29 | ...danger.git.modified_files, 30 | ...danger.git.deleted_files, 31 | ] 32 | .filter(filename => { 33 | const { dir, ext } = parse(filename); 34 | 35 | return dir === 'src/rules' && ext === '.ts'; 36 | }) 37 | .map(filename => parse(filename).name); 38 | 39 | await Promise.all( 40 | affectedRules.map(rule => 41 | createOrAddLabelSafely(`rule: ${rule}`, '#7d3abc'), 42 | ), 43 | ); 44 | }; 45 | 46 | const labelBasedOnTitle = async (): Promise => { 47 | if (danger.github.pr.title.startsWith('feat')) { 48 | return createOrAddLabelSafely('enhancement', '#84b6eb'); 49 | } 50 | 51 | if (danger.github.pr.title.startsWith('fix')) { 52 | return createOrAddLabelSafely('bug', '#ee0701'); 53 | } 54 | 55 | return false; 56 | }; 57 | 58 | const labelBasedOnCommits = async () => { 59 | const commits = danger.github.commits.map(commits => commits.commit.message); 60 | 61 | if (commits.some(commit => commit.startsWith('fix'))) { 62 | await createOrAddLabelSafely('bug', '#ee0701'); 63 | } 64 | 65 | if (commits.some(commit => commit.startsWith('feat'))) { 66 | await createOrAddLabelSafely('enhancement', '#84b6eb'); 67 | } 68 | }; 69 | 70 | const labelBasedOnTitleOrCommits = async () => { 71 | // prioritize labeling based on the title since most pull requests will get 72 | // squashed into a single commit with the title as the subject, but fallback 73 | // to looking through the commits if we can't determine a label from the title 74 | if (await labelBasedOnTitle()) { 75 | return; 76 | } 77 | 78 | await labelBasedOnCommits(); 79 | }; 80 | 81 | Promise.all([labelBasedOnRules(), labelBasedOnTitleOrCommits()]).catch( 82 | error => { 83 | console.error(error); 84 | fail(`Something went very wrong: ${error}`); 85 | }, 86 | ); 87 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-be-array.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toBeArray()` (`prefer-to-be-array`) 2 | 3 | 🔧 This rule is automatically fixable by the 4 | [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 5 | 6 | 7 | 8 | For expecting a value to be an array, `jest-extended` provides the `toBeArray` 9 | matcher. 10 | 11 | ## Rule details 12 | 13 | This rule triggers a warning if an `expect` assertion is found asserting that a 14 | value is an array using one of the following methods: 15 | 16 | - Comparing the result of `Array.isArary()` to a boolean value, 17 | - Comparing the result of ` instanceof Array` to a boolean value, 18 | - Calling the `toBeInstanceOf` matcher with the `Array` class. 19 | 20 | The following patterns are considered warnings: 21 | 22 | ```js 23 | expect(Array.isArray([])).toBe(true); 24 | 25 | expect(Array.isArray(myValue)).toStrictEqual(false); 26 | 27 | expect(Array.isArray(theResults())).not.toBeFalse(); 28 | 29 | expect([] instanceof Array).toBe(true); 30 | 31 | expect(myValue instanceof Array).toStrictEqual(false); 32 | 33 | expect(theResults() instanceof Array).not.toBeFalse(); 34 | 35 | expect([]).toBeInstanceOf(true); 36 | 37 | expect(myValue).resolves.toBeInstanceOf(Array); 38 | 39 | expect(theResults()).not.toBeInstanceOf(Array); 40 | ``` 41 | 42 | The following patterns are _not_ considered warnings: 43 | 44 | ```js 45 | expect(Array.isArray([])).toBeArray(); 46 | 47 | expect(Array.isArray(myValue)).not.toBeArray(); 48 | 49 | expect(myValue).resolves.toBeArray(); 50 | 51 | expect(Array.isArray(theResults())).toBeArray(); 52 | ``` 53 | 54 | ## Further Reading 55 | 56 | - [`jest-extended#toBeArray` matcher](https://github.com/jest-community/jest-extended#tobearray) 57 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-be-false.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toBeFalse()` (`prefer-to-be-false`) 2 | 3 | 🔧 This rule is automatically fixable by the 4 | [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 5 | 6 | 7 | 8 | For expecting a value to be `false`, `jest-extended` provides the `toBeFalse` 9 | matcher. 10 | 11 | ## Rule details 12 | 13 | This rule triggers a warning if `toBe()`, `toEqual()` or `toStrictEqual()` is 14 | used to assert against the `false` literal. 15 | 16 | The following patterns are considered warnings: 17 | 18 | ```js 19 | expect(true).toBe(false); 20 | 21 | expect(wasSuccessful).toEqual(false); 22 | 23 | expect(fs.existsSync('/path/to/file')).toStrictEqual(false); 24 | ``` 25 | 26 | The following patterns are _not_ considered warnings: 27 | 28 | ```js 29 | expect(true).toBeFalse(); 30 | 31 | expect(wasSuccessful).toBeFalse(); 32 | 33 | expect(fs.existsSync('/path/to/file')).toBeFalse(); 34 | 35 | test('returns false', () => { 36 | expect(areWeThereYet()).toBeFalse(); 37 | expect(true).not.toBeFalse(); 38 | }); 39 | ``` 40 | 41 | ## Further Reading 42 | 43 | - [`jest-extended#toBeFalse` matcher](https://github.com/jest-community/jest-extended#tobefalse) 44 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-be-object.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toBeObject()` (`prefer-to-be-object`) 2 | 3 | 🔧 This rule is automatically fixable by the 4 | [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 5 | 6 | 7 | 8 | For expecting a value to be an object, `jest-extended` provides the `toBeObject` 9 | matcher. 10 | 11 | ## Rule details 12 | 13 | This rule triggers a warning if an `expect` assertion is found asserting that a 14 | value is an object using one of the following methods: 15 | 16 | - Comparing the result of ` instanceof Object` to a boolean value, 17 | - Calling the `toBeInstanceOf` matcher with the `Object` class. 18 | 19 | The following patterns are considered warnings: 20 | 21 | ```js 22 | expect([] instanceof Object).toBe(true); 23 | 24 | expect(myValue instanceof Object).toStrictEqual(false); 25 | 26 | expect(theResults() instanceof Object).not.toBeFalse(); 27 | 28 | expect([]).toBeInstanceOf(true); 29 | 30 | expect(myValue).resolves.toBeInstanceOf(Object); 31 | 32 | expect(theResults()).not.toBeInstanceOf(Object); 33 | ``` 34 | 35 | The following patterns are _not_ considered warnings: 36 | 37 | ```js 38 | expect({}).toBeObject(); 39 | 40 | expect(myValue).not.toBeObject(); 41 | 42 | expect(queryApi()).resolves.toBeObject(); 43 | 44 | expect(theResults()).toBeObject(); 45 | ``` 46 | 47 | ## Further Reading 48 | 49 | - [`jest-extended#toBeObject` matcher](https://github.com/jest-community/jest-extended#tobeobject) 50 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-be-true.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toBeTrue()` (`prefer-to-be-true`) 2 | 3 | 🔧 This rule is automatically fixable by the 4 | [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 5 | 6 | 7 | 8 | For expecting a value to be `true`, `jest-extended` provides the `toBeTrue` 9 | matcher. 10 | 11 | ## Rule details 12 | 13 | This rule triggers a warning if `toBe()`, `toEqual()` or `toStrictEqual()` is 14 | used to assert against the `true` literal. 15 | 16 | The following patterns are considered warnings: 17 | 18 | ```js 19 | expect(false).toBe(true); 20 | 21 | expect(wasSuccessful).toEqual(true); 22 | 23 | expect(fs.existsSync('/path/to/file')).toStrictEqual(true); 24 | ``` 25 | 26 | The following patterns are _not_ considered warnings: 27 | 28 | ```js 29 | expect(false).toBeTrue(); 30 | 31 | expect(wasSuccessful).toBeTrue(); 32 | 33 | expect(fs.existsSync('/path/to/file')).toBeTrue(); 34 | 35 | test('is jest cool', () => { 36 | expect(isJestCool()).toBeTrue(); 37 | expect(false).not.toBeTrue(); 38 | }); 39 | ``` 40 | 41 | ## Further Reading 42 | 43 | - [`jest-extended#toBeTrue` matcher](https://github.com/jest-community/jest-extended#tobetrue) 44 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-have-been-called-once.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toHaveBeenCalledOnce()` (`prefer-to-have-been-called-once`) 2 | 3 | 🔧 This rule is automatically fixable by the 4 | [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 5 | 6 | 7 | 8 | For expecting a mock or spy to have been called once, `jest-extended` provides 9 | the `toHaveBeenCalledOnce` matcher. 10 | 11 | ## Rule details 12 | 13 | This rule triggers a warning if an `expect` assertion is found asserting that a 14 | mock or spy is called once using `toHaveBeenCalledTimes(1)`. 15 | 16 | The following patterns are considered warnings: 17 | 18 | ```js 19 | expect(myMock).toHaveBeenCalledTimes(1); 20 | expect(mySpy).not.toHaveBeenCalledTimes(1); 21 | ``` 22 | 23 | The following patterns are _not_ considered warnings: 24 | 25 | ```js 26 | expect(myMock).toHaveBeenCalledOnce(); 27 | expect(mySpy).not.toHaveBeenCalledOnce(); 28 | ``` 29 | 30 | ## Further Reading 31 | 32 | - [`jest-extended#toHaveBeenCalledOnce` matcher](https://github.com/jest-community/jest-extended#tohavebeencalledonce) 33 | -------------------------------------------------------------------------------- /eslint-remote-tester.config.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import type { Config } from 'eslint-remote-tester'; 4 | import { 5 | getPathIgnorePattern, 6 | getRepositories, 7 | } from 'eslint-remote-tester-repositories'; 8 | 9 | const config: Config = { 10 | repositories: getRepositories({ randomize: true }), 11 | pathIgnorePattern: getPathIgnorePattern(), 12 | extensions: ['js', 'jsx', 'ts', 'tsx'], 13 | concurrentTasks: 3, 14 | cache: false, 15 | logLevel: 'info', 16 | eslintrc: { 17 | root: true, 18 | env: { 19 | es6: true, 20 | }, 21 | parser: '@typescript-eslint/parser', 22 | parserOptions: { 23 | ecmaVersion: 2020, 24 | sourceType: 'module', 25 | ecmaFeatures: { 26 | jsx: true, 27 | }, 28 | }, 29 | extends: ['plugin:jest-extended/all'], 30 | rules: {}, 31 | }, 32 | }; 33 | 34 | export default config; 35 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { version as eslintVersion } from 'eslint/package.json'; 2 | import type { Config } from 'jest'; 3 | import * as semver from 'semver'; 4 | 5 | const config = { 6 | clearMocks: true, 7 | restoreMocks: true, 8 | resetMocks: true, 9 | 10 | coverageThreshold: { 11 | global: { 12 | branches: 100, 13 | functions: 100, 14 | lines: 100, 15 | statements: 100, 16 | }, 17 | }, 18 | 19 | projects: [ 20 | { 21 | displayName: 'test', 22 | testPathIgnorePatterns: [ 23 | '/lib/.*', 24 | '/src/rules/__tests__/test-utils.ts', 25 | ], 26 | coveragePathIgnorePatterns: ['/node_modules/'], 27 | }, 28 | { 29 | displayName: 'lint', 30 | runner: 'jest-runner-eslint', 31 | testMatch: ['/**/*.{js,ts}'], 32 | testPathIgnorePatterns: ['/lib/.*'], 33 | coveragePathIgnorePatterns: ['/node_modules/'], 34 | }, 35 | ], 36 | } satisfies Config; 37 | 38 | if (semver.major(eslintVersion) >= 9) { 39 | config.projects = config.projects.filter( 40 | ({ displayName }) => displayName !== 'lint', 41 | ); 42 | } 43 | 44 | export default config; 45 | -------------------------------------------------------------------------------- /markdown_link_check_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "httpHeaders": [ 3 | { 4 | "urls": ["https://docs.github.com"], 5 | "headers": { 6 | "Accept-Encoding": "br, gzip, deflate" 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-jest-extended", 3 | "version": "3.0.0", 4 | "description": "Eslint rules for Jest Extended", 5 | "keywords": [ 6 | "jest", 7 | "eslint", 8 | "eslintplugin", 9 | "eslint-plugin", 10 | "eslint-plugin-jest" 11 | ], 12 | "homepage": "https://github.com/jest-community/eslint-plugin-jest-extended#readme", 13 | "bugs": { 14 | "url": "https://github.com/jest-community/eslint-plugin-jest-extended/issues" 15 | }, 16 | "repository": "https://github.com/jest-community/eslint-plugin-jest-extended", 17 | "license": "MIT", 18 | "main": "lib/", 19 | "directories": { 20 | "lib": "lib", 21 | "doc": "docs" 22 | }, 23 | "files": [ 24 | "docs/", 25 | "lib/" 26 | ], 27 | "scripts": { 28 | "build": "babel --extensions .js,.ts src --out-dir lib --copy-files && rimraf --glob lib/__tests__ 'lib/**/__tests__'", 29 | "postinstall": "is-ci || husky", 30 | "lint": "eslint . --ignore-pattern '!.eslintrc.js' --ext js,ts", 31 | "prepack": "rimraf --glob lib && yarn build", 32 | "prepublishOnly": "pinst --disable", 33 | "prettier:check": "prettier --check .", 34 | "prettier:write": "prettier --write .", 35 | "postpublish": "pinst --enable", 36 | "test": "jest", 37 | "tools:regenerate-docs": "yarn prepack && eslint-doc-generator", 38 | "typecheck": "tsc -p ." 39 | }, 40 | "commitlint": { 41 | "extends": [ 42 | "@commitlint/config-conventional" 43 | ] 44 | }, 45 | "lint-staged": { 46 | "*.{js,ts}": "eslint --fix", 47 | "*.{md,json,yml}": "prettier --write" 48 | }, 49 | "prettier": { 50 | "arrowParens": "avoid", 51 | "endOfLine": "auto", 52 | "proseWrap": "always", 53 | "singleQuote": true 54 | }, 55 | "release": { 56 | "branches": [ 57 | "main", 58 | { 59 | "name": "next", 60 | "prerelease": true 61 | } 62 | ], 63 | "plugins": [ 64 | "@semantic-release/commit-analyzer", 65 | "@semantic-release/release-notes-generator", 66 | "@semantic-release/changelog", 67 | "@semantic-release/npm", 68 | "@semantic-release/git", 69 | "@semantic-release/github" 70 | ] 71 | }, 72 | "dependencies": { 73 | "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0" 74 | }, 75 | "devDependencies": { 76 | "@babel/cli": "^7.4.4", 77 | "@babel/core": "^7.4.4", 78 | "@babel/preset-env": "^7.4.4", 79 | "@babel/preset-typescript": "^7.3.3", 80 | "@commitlint/cli": "^19.0.0", 81 | "@commitlint/config-conventional": "^19.0.0", 82 | "@eslint-community/eslint-plugin-eslint-comments": "^4.4.1", 83 | "@schemastore/package": "^0.0.10", 84 | "@semantic-release/changelog": "^6.0.0", 85 | "@semantic-release/git": "^10.0.0", 86 | "@tsconfig/node16": "^16.0.0", 87 | "@types/dedent": "^0.7.0", 88 | "@types/jest": "^29.0.0", 89 | "@types/node": "^16.0.0", 90 | "@types/semver": "^7.5.8", 91 | "@typescript-eslint/eslint-plugin": "^6.0.0", 92 | "@typescript-eslint/parser": "^6.0.0", 93 | "@typescript-eslint/utils": "^6.0.0", 94 | "babel-jest": "^29.0.0", 95 | "babel-plugin-replace-ts-export-assignment": "^0.0.2", 96 | "dedent": "^1.0.0", 97 | "eslint": "^7.0.0 || ^8.0.0", 98 | "eslint-config-prettier": "^10.0.0", 99 | "eslint-doc-generator": "^1.0.0", 100 | "eslint-plugin-eslint-plugin": "^6.0.0", 101 | "eslint-plugin-import": "^2.18.0", 102 | "eslint-plugin-n": "^17.0.0", 103 | "eslint-plugin-prettier": "^5.0.0", 104 | "eslint-remote-tester": "^3.0.0", 105 | "eslint-remote-tester-repositories": "^1.0.0", 106 | "husky": "^9.0.0", 107 | "is-ci": "^4.0.0", 108 | "jest": "^29.0.0", 109 | "jest-runner-eslint": "^2.0.0", 110 | "lint-staged": "^13.0.3", 111 | "markdown-link-check": "~3.12.0", 112 | "pinst": "^3.0.0", 113 | "prettier": "^3.0.0", 114 | "rimraf": "^5.0.0", 115 | "semantic-release": "^24.0.0", 116 | "semver": "^7.6.0", 117 | "ts-node": "^10.9.1", 118 | "typescript": "^5.0.0" 119 | }, 120 | "peerDependencies": { 121 | "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" 122 | }, 123 | "packageManager": "yarn@3.8.7", 124 | "engines": { 125 | "node": "^16.10.0 || ^18.12.0 || >=20.0.0" 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/rules.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`rules should export configs that refer to actual rules 1`] = ` 4 | { 5 | "all": { 6 | "plugins": [ 7 | "jest-extended", 8 | ], 9 | "rules": { 10 | "jest-extended/prefer-to-be-array": "error", 11 | "jest-extended/prefer-to-be-false": "error", 12 | "jest-extended/prefer-to-be-object": "error", 13 | "jest-extended/prefer-to-be-true": "error", 14 | "jest-extended/prefer-to-have-been-called-once": "error", 15 | }, 16 | }, 17 | "flat/all": { 18 | "plugins": { 19 | "jest-extended": ObjectContaining { 20 | "meta": { 21 | "name": "eslint-plugin-jest-extended", 22 | "version": Any, 23 | }, 24 | }, 25 | }, 26 | "rules": { 27 | "jest-extended/prefer-to-be-array": "error", 28 | "jest-extended/prefer-to-be-false": "error", 29 | "jest-extended/prefer-to-be-object": "error", 30 | "jest-extended/prefer-to-be-true": "error", 31 | "jest-extended/prefer-to-have-been-called-once": "error", 32 | }, 33 | }, 34 | "flat/recommended": { 35 | "plugins": { 36 | "jest-extended": ObjectContaining { 37 | "meta": { 38 | "name": "eslint-plugin-jest-extended", 39 | "version": Any, 40 | }, 41 | }, 42 | }, 43 | "rules": {}, 44 | }, 45 | "recommended": { 46 | "plugins": [ 47 | "jest-extended", 48 | ], 49 | "rules": {}, 50 | }, 51 | } 52 | `; 53 | -------------------------------------------------------------------------------- /src/__tests__/rules.test.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs'; 2 | import { resolve } from 'path'; 3 | import plugin from '../'; 4 | 5 | const ruleNames = Object.keys(plugin.rules); 6 | const numberOfRules = 5; 7 | 8 | describe('rules', () => { 9 | it('should have a corresponding doc for each rule', () => { 10 | ruleNames.forEach(rule => { 11 | const docPath = resolve(__dirname, '../../docs/rules', `${rule}.md`); 12 | 13 | if (!existsSync(docPath)) { 14 | throw new Error( 15 | `Could not find documentation file for rule "${rule}" in path "${docPath}"`, 16 | ); 17 | } 18 | }); 19 | }); 20 | 21 | it('should have the correct amount of rules', () => { 22 | const { length } = ruleNames; 23 | 24 | if (length !== numberOfRules) { 25 | throw new Error( 26 | `There should be exactly ${numberOfRules} rules, but there are ${length}. If you've added a new rule, please update this number.`, 27 | ); 28 | } 29 | }); 30 | 31 | it('should export configs that refer to actual rules', () => { 32 | const expectJestExtendedPlugin = expect.objectContaining({ 33 | meta: { 34 | name: 'eslint-plugin-jest-extended', 35 | version: expect.any(String), 36 | }, 37 | }); 38 | 39 | const recommendedConfigs = plugin.configs; 40 | 41 | expect(recommendedConfigs).toMatchSnapshot({ 42 | 'flat/recommended': { 43 | plugins: { 'jest-extended': expectJestExtendedPlugin }, 44 | }, 45 | 'flat/all': { 46 | plugins: { 'jest-extended': expectJestExtendedPlugin }, 47 | }, 48 | }); 49 | expect(Object.keys(recommendedConfigs)).toEqual([ 50 | 'all', 51 | 'recommended', 52 | 'flat/all', 53 | 'flat/recommended', 54 | ]); 55 | expect(Object.keys(recommendedConfigs.all.rules)).toHaveLength( 56 | ruleNames.length, 57 | ); 58 | expect(Object.keys(recommendedConfigs['flat/all'].rules)).toHaveLength( 59 | ruleNames.length, 60 | ); 61 | const allConfigRules = Object.values(recommendedConfigs) 62 | .map(config => Object.keys(config.rules ?? {})) 63 | .reduce((previousValue, currentValue) => [ 64 | ...previousValue, 65 | ...currentValue, 66 | ]); 67 | 68 | allConfigRules.forEach(rule => { 69 | const ruleNamePrefix = 'jest-extended/'; 70 | const ruleName = rule.slice(ruleNamePrefix.length); 71 | 72 | expect(rule.startsWith(ruleNamePrefix)).toBe(true); 73 | expect(ruleNames).toContain(ruleName); 74 | // eslint-disable-next-line @typescript-eslint/no-require-imports 75 | expect(() => require(`../rules/${ruleName}`)).not.toThrow(); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync } from 'fs'; 2 | import { join, parse } from 'path'; 3 | import type { TSESLint } from '@typescript-eslint/utils'; 4 | import { 5 | name as packageName, 6 | version as packageVersion, 7 | } from '../package.json'; 8 | 9 | type RuleModule = TSESLint.RuleModule & { 10 | meta: Required, 'docs'>>; 11 | }; 12 | 13 | // copied from https://github.com/babel/babel/blob/d8da63c929f2d28c401571e2a43166678c555bc4/packages/babel-helpers/src/helpers.js#L602-L606 14 | /* istanbul ignore next */ 15 | const interopRequireDefault = (obj: any): { default: any } => 16 | obj && obj.__esModule ? obj : { default: obj }; 17 | 18 | const importDefault = (moduleName: string) => 19 | // eslint-disable-next-line @typescript-eslint/no-require-imports 20 | interopRequireDefault(require(moduleName)).default; 21 | 22 | const rulesDir = join(__dirname, 'rules'); 23 | const excludedFiles = ['__tests__', 'utils']; 24 | 25 | const rules = Object.fromEntries( 26 | readdirSync(rulesDir) 27 | .map(rule => parse(rule).name) 28 | .filter(rule => !excludedFiles.includes(rule)) 29 | .map(rule => [rule, importDefault(join(rulesDir, rule)) as RuleModule]), 30 | ); 31 | 32 | const recommendedRules = {} satisfies Record; 33 | 34 | const allRules = Object.fromEntries( 35 | Object.entries(rules) 36 | .filter(([, rule]) => !rule.meta.deprecated) 37 | .map(([name]) => [`jest-extended/${name}`, 'error']), 38 | ); 39 | 40 | const plugin = { 41 | meta: { name: packageName, version: packageVersion }, 42 | // ugly cast for now to keep TypeScript happy since 43 | // we don't have types for flat config yet 44 | configs: {} as Record< 45 | 'all' | 'recommended' | 'flat/all' | 'flat/recommended', 46 | Pick, 'rules'> 47 | >, 48 | rules, 49 | }; 50 | 51 | const createRCConfig = (rules: Record) => ({ 52 | plugins: ['jest-extended'], 53 | rules, 54 | }); 55 | 56 | const createFlatConfig = ( 57 | rules: Record, 58 | ) => ({ 59 | plugins: { 'jest-extended': plugin }, 60 | rules, 61 | }); 62 | 63 | plugin.configs = { 64 | all: createRCConfig(allRules), 65 | recommended: createRCConfig(recommendedRules), 66 | 'flat/all': createFlatConfig(allRules), 67 | 'flat/recommended': createFlatConfig(recommendedRules), 68 | }; 69 | 70 | export = plugin; 71 | -------------------------------------------------------------------------------- /src/rules/__tests__/prefer-to-be-array.test.ts: -------------------------------------------------------------------------------- 1 | import type { TSESLint } from '@typescript-eslint/utils'; 2 | import rule, { type MessageIds, type Options } from '../prefer-to-be-array'; 3 | import { FlatCompatRuleTester as RuleTester, espreeParser } from './test-utils'; 4 | 5 | const ruleTester = new RuleTester({ 6 | parser: espreeParser, 7 | parserOptions: { 8 | ecmaVersion: 2017, 9 | }, 10 | }); 11 | 12 | // makes ts happy about the dynamic test generation 13 | const messageId = 'preferToBeArray' as const; 14 | 15 | const expectInAndOutValues = [ 16 | ['[] instanceof Array', '[]'], 17 | ['Array.isArray([])', '([])'], 18 | ]; 19 | 20 | const createTestsForEqualityMatchers = (): Array< 21 | TSESLint.InvalidTestCase 22 | > => 23 | ['toBe', 'toEqual', 'toStrictEqual'] 24 | .map(matcher => 25 | expectInAndOutValues.map(([inValue, outValue]) => [ 26 | { 27 | code: `expect(${inValue}).${matcher}(true);`, 28 | errors: [{ messageId, column: 10 + inValue.length, line: 1 }], 29 | output: `expect(${outValue}).toBeArray();`, 30 | }, 31 | { 32 | code: `expect(${inValue}).not.${matcher}(true);`, 33 | errors: [{ messageId, column: 14 + inValue.length, line: 1 }], 34 | output: `expect(${outValue}).not.toBeArray();`, 35 | }, 36 | { 37 | code: `expect(${inValue}).${matcher}(false);`, 38 | errors: [{ messageId, column: 10 + inValue.length, line: 1 }], 39 | output: `expect(${outValue}).not.toBeArray();`, 40 | }, 41 | { 42 | code: `expect(${inValue}).not.${matcher}(false);`, 43 | errors: [{ messageId, column: 14 + inValue.length, line: 1 }], 44 | output: `expect(${outValue}).toBeArray();`, 45 | }, 46 | ]), 47 | ) 48 | .reduce((arr, cur) => arr.concat(cur), []) 49 | .reduce((arr, cur) => arr.concat(cur), []); 50 | 51 | ruleTester.run('prefer-to-be-array', rule, { 52 | valid: [ 53 | 'expect.hasAssertions', 54 | 'expect.hasAssertions()', 55 | 'expect', 56 | 'expect()', 57 | 'expect().toBe(true)', 58 | 'expect([]).toBe(true)', 59 | 'expect([])["toBe"](true)', 60 | 'expect([]).toBeArray()', 61 | 'expect([]).not.toBeArray()', 62 | 'expect([] instanceof Object).not.toBeArray()', 63 | 'expect([]).not.toBeInstanceOf(Object)', 64 | ], 65 | invalid: [ 66 | ...createTestsForEqualityMatchers(), 67 | ...expectInAndOutValues 68 | .map(([inValue, outValue]) => [ 69 | { 70 | code: `expect(${inValue}).toBeTrue();`, 71 | errors: [{ messageId, column: 10 + inValue.length, line: 1 }], 72 | output: `expect(${outValue}).toBeArray();`, 73 | }, 74 | { 75 | code: `expect(${inValue}).not.toBeTrue();`, 76 | errors: [{ messageId, column: 14 + inValue.length, line: 1 }], 77 | output: `expect(${outValue}).not.toBeArray();`, 78 | }, 79 | { 80 | code: `expect(${inValue}).toBeFalse();`, 81 | errors: [{ messageId, column: 10 + inValue.length, line: 1 }], 82 | output: `expect(${outValue}).not.toBeArray();`, 83 | }, 84 | { 85 | code: `expect(${inValue}).not.toBeFalse();`, 86 | errors: [{ messageId, column: 14 + inValue.length, line: 1 }], 87 | output: `expect(${outValue}).toBeArray();`, 88 | }, 89 | ]) 90 | .reduce((arr, cur) => arr.concat(cur), []), 91 | 92 | { 93 | code: 'expect(Array["isArray"]([])).toBe(true);', 94 | output: 'expect(([])).toBeArray();', 95 | errors: [{ messageId, column: 30, line: 1 }], 96 | }, 97 | { 98 | code: 'expect(Array[`isArray`]([])).toBe(true);', 99 | output: 'expect(([])).toBeArray();', 100 | errors: [{ messageId, column: 30, line: 1 }], 101 | }, 102 | { 103 | code: 'expect([]).toBeInstanceOf(Array);', 104 | output: 'expect([]).toBeArray();', 105 | errors: [{ messageId, column: 12, line: 1 }], 106 | }, 107 | { 108 | code: 'expect([]).not.toBeInstanceOf(Array);', 109 | output: 'expect([]).not.toBeArray();', 110 | errors: [{ messageId, column: 16, line: 1 }], 111 | }, 112 | { 113 | code: 'expect(requestValues()).resolves.toBeInstanceOf(Array);', 114 | output: 'expect(requestValues()).resolves.toBeArray();', 115 | errors: [{ messageId, column: 34, line: 1 }], 116 | }, 117 | { 118 | code: 'expect(queryApi()).rejects.not.toBeInstanceOf(Array);', 119 | output: 'expect(queryApi()).rejects.not.toBeArray();', 120 | errors: [{ messageId, column: 32, line: 1 }], 121 | }, 122 | ], 123 | }); 124 | -------------------------------------------------------------------------------- /src/rules/__tests__/prefer-to-be-false.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../prefer-to-be-false'; 2 | import { FlatCompatRuleTester as RuleTester } from './test-utils'; 3 | 4 | const ruleTester = new RuleTester(); 5 | 6 | ruleTester.run('prefer-to-be-false', rule, { 7 | valid: [ 8 | '[].push(false)', 9 | 'expect("something");', 10 | 'expect(true).toBeTrue();', 11 | 'expect(false).toBeTrue();', 12 | 'expect(false).toBeFalse();', 13 | 'expect(true).toBeFalse();', 14 | 'expect(value).toEqual();', 15 | 'expect(value).not.toBeFalse();', 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 | ], 27 | invalid: [ 28 | { 29 | code: 'expect(true).toBe(false);', 30 | output: 'expect(true).toBeFalse();', 31 | errors: [{ messageId: 'preferToBeFalse', column: 14, line: 1 }], 32 | }, 33 | { 34 | code: 'expect(wasSuccessful).toEqual(false);', 35 | output: 'expect(wasSuccessful).toBeFalse();', 36 | errors: [{ messageId: 'preferToBeFalse', column: 23, line: 1 }], 37 | }, 38 | { 39 | code: "expect(fs.existsSync('/path/to/file')).toStrictEqual(false);", 40 | output: "expect(fs.existsSync('/path/to/file')).toBeFalse();", 41 | errors: [{ messageId: 'preferToBeFalse', column: 40, line: 1 }], 42 | }, 43 | { 44 | code: 'expect("a string").not.toBe(false);', 45 | output: 'expect("a string").not.toBeFalse();', 46 | errors: [{ messageId: 'preferToBeFalse', column: 24, line: 1 }], 47 | }, 48 | { 49 | code: 'expect("a string").not.toEqual(false);', 50 | output: 'expect("a string").not.toBeFalse();', 51 | errors: [{ messageId: 'preferToBeFalse', column: 24, line: 1 }], 52 | }, 53 | { 54 | code: 'expect("a string").not.toStrictEqual(false);', 55 | output: 'expect("a string").not.toBeFalse();', 56 | errors: [{ messageId: 'preferToBeFalse', column: 24, line: 1 }], 57 | }, 58 | ], 59 | }); 60 | 61 | new RuleTester({ 62 | parser: require.resolve('@typescript-eslint/parser'), 63 | }).run('prefer-to-be-false: typescript edition', rule, { 64 | valid: [ 65 | "(expect('Model must be bound to an array if the multiple property is true') as any).toHaveBeenTipped()", 66 | ], 67 | invalid: [ 68 | { 69 | code: 'expect(true).toBe(false as unknown as string as unknown as any);', 70 | output: 'expect(true).toBeFalse();', 71 | errors: [{ messageId: 'preferToBeFalse', column: 14, line: 1 }], 72 | }, 73 | { 74 | code: 'expect("a string").not.toEqual(false as number);', 75 | output: 'expect("a string").not.toBeFalse();', 76 | errors: [{ messageId: 'preferToBeFalse', column: 24, line: 1 }], 77 | }, 78 | ], 79 | }); 80 | -------------------------------------------------------------------------------- /src/rules/__tests__/prefer-to-be-object.test.ts: -------------------------------------------------------------------------------- 1 | import type { TSESLint } from '@typescript-eslint/utils'; 2 | import rule, { type MessageIds, type Options } from '../prefer-to-be-object'; 3 | import { FlatCompatRuleTester as RuleTester } from './test-utils'; 4 | 5 | const ruleTester = new RuleTester(); 6 | 7 | // makes ts happy about the dynamic test generation 8 | const messageId = 'preferToBeObject' as const; 9 | 10 | const createTestsForEqualityMatchers = (): Array< 11 | TSESLint.InvalidTestCase 12 | > => 13 | ['toBe', 'toEqual', 'toStrictEqual'] 14 | .map(matcher => [ 15 | { 16 | code: `expect({} instanceof Object).${matcher}(true);`, 17 | errors: [{ messageId, column: 30, line: 1 }], 18 | output: `expect({}).toBeObject();`, 19 | }, 20 | { 21 | code: `expect({} instanceof Object).not.${matcher}(true);`, 22 | errors: [{ messageId, column: 34, line: 1 }], 23 | output: `expect({}).not.toBeObject();`, 24 | }, 25 | { 26 | code: `expect({} instanceof Object).${matcher}(false);`, 27 | errors: [{ messageId, column: 30, line: 1 }], 28 | output: `expect({}).not.toBeObject();`, 29 | }, 30 | { 31 | code: `expect({} instanceof Object).not.${matcher}(false);`, 32 | errors: [{ messageId, column: 34, line: 1 }], 33 | output: `expect({}).toBeObject();`, 34 | }, 35 | ]) 36 | .reduce((arr, cur) => arr.concat(cur), []); 37 | 38 | ruleTester.run('prefer-to-be-object', rule, { 39 | valid: [ 40 | 'expect.hasAssertions', 41 | 'expect.hasAssertions()', 42 | 'expect', 43 | 'expect().not', 44 | 'expect().toBe', 45 | 'expect().toBe(true)', 46 | 'expect({}).toBe(true)', 47 | 'expect({}).toBeObject()', 48 | 'expect({}).not.toBeObject()', 49 | 'expect([] instanceof Array).not.toBeObject()', 50 | 'expect({}).not.toBeInstanceOf(Array)', 51 | ], 52 | invalid: [ 53 | ...createTestsForEqualityMatchers(), 54 | { 55 | code: 'expect(({} instanceof Object)).toBeTrue();', 56 | output: 'expect(({})).toBeObject();', 57 | errors: [{ messageId, column: 32, line: 1 }], 58 | }, 59 | { 60 | code: 'expect({} instanceof Object).toBeTrue();', 61 | output: 'expect({}).toBeObject();', 62 | errors: [{ messageId, column: 30, line: 1 }], 63 | }, 64 | { 65 | code: 'expect({} instanceof Object).not.toBeTrue();', 66 | output: 'expect({}).not.toBeObject();', 67 | errors: [{ messageId, column: 34, line: 1 }], 68 | }, 69 | { 70 | code: 'expect({} instanceof Object).toBeFalse();', 71 | output: 'expect({}).not.toBeObject();', 72 | errors: [{ messageId, column: 30, line: 1 }], 73 | }, 74 | { 75 | code: 'expect({} instanceof Object).not.toBeFalse();', 76 | output: 'expect({}).toBeObject();', 77 | errors: [{ messageId, column: 34, line: 1 }], 78 | }, 79 | { 80 | code: 'expect({}).toBeInstanceOf(Object);', 81 | output: 'expect({}).toBeObject();', 82 | errors: [{ messageId, column: 12, line: 1 }], 83 | }, 84 | { 85 | code: 'expect({}).not.toBeInstanceOf(Object);', 86 | output: 'expect({}).not.toBeObject();', 87 | errors: [{ messageId, column: 16, line: 1 }], 88 | }, 89 | { 90 | code: 'expect(requestValues()).resolves.toBeInstanceOf(Object);', 91 | output: 'expect(requestValues()).resolves.toBeObject();', 92 | errors: [{ messageId, column: 34, line: 1 }], 93 | }, 94 | { 95 | code: 'expect(queryApi()).resolves.not.toBeInstanceOf(Object);', 96 | output: 'expect(queryApi()).resolves.not.toBeObject();', 97 | errors: [{ messageId, column: 33, line: 1 }], 98 | }, 99 | ], 100 | }); 101 | -------------------------------------------------------------------------------- /src/rules/__tests__/prefer-to-be-true.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../prefer-to-be-true'; 2 | import { FlatCompatRuleTester as RuleTester } from './test-utils'; 3 | 4 | const ruleTester = new RuleTester(); 5 | 6 | ruleTester.run('prefer-to-be-true', rule, { 7 | valid: [ 8 | '[].push(true)', 9 | 'expect("something");', 10 | 'expect(true).toBeTrue();', 11 | 'expect(false).toBeTrue();', 12 | 'expect(false).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 | ], 27 | invalid: [ 28 | { 29 | code: 'expect(false).toBe(true);', 30 | output: 'expect(false).toBeTrue();', 31 | errors: [{ messageId: 'preferToBeTrue', column: 15, line: 1 }], 32 | }, 33 | { 34 | code: 'expect(wasSuccessful).toEqual(true);', 35 | output: 'expect(wasSuccessful).toBeTrue();', 36 | errors: [{ messageId: 'preferToBeTrue', column: 23, line: 1 }], 37 | }, 38 | { 39 | code: "expect(fs.existsSync('/path/to/file')).toStrictEqual(true);", 40 | output: "expect(fs.existsSync('/path/to/file')).toBeTrue();", 41 | errors: [{ messageId: 'preferToBeTrue', column: 40, line: 1 }], 42 | }, 43 | { 44 | code: 'expect("a string").not.toBe(true);', 45 | output: 'expect("a string").not.toBeTrue();', 46 | errors: [{ messageId: 'preferToBeTrue', column: 24, line: 1 }], 47 | }, 48 | { 49 | code: 'expect("a string").not.toEqual(true);', 50 | output: 'expect("a string").not.toBeTrue();', 51 | errors: [{ messageId: 'preferToBeTrue', column: 24, line: 1 }], 52 | }, 53 | { 54 | code: 'expect("a string").not.toStrictEqual(true);', 55 | output: 'expect("a string").not.toBeTrue();', 56 | errors: [{ messageId: 'preferToBeTrue', column: 24, line: 1 }], 57 | }, 58 | ], 59 | }); 60 | 61 | new RuleTester({ 62 | parser: require.resolve('@typescript-eslint/parser'), 63 | }).run('prefer-to-be-true: typescript edition', rule, { 64 | valid: [ 65 | "(expect('Model must be bound to an array if the multiple property is true') as any).toHaveBeenTipped()", 66 | ], 67 | invalid: [ 68 | { 69 | code: 'expect(true).toBe(true as unknown as string as unknown as any);', 70 | output: 'expect(true).toBeTrue();', 71 | errors: [{ messageId: 'preferToBeTrue', column: 14, line: 1 }], 72 | }, 73 | { 74 | code: 'expect("a string").not.toEqual(true as number);', 75 | output: 'expect("a string").not.toBeTrue();', 76 | errors: [{ messageId: 'preferToBeTrue', column: 24, line: 1 }], 77 | }, 78 | ], 79 | }); 80 | -------------------------------------------------------------------------------- /src/rules/__tests__/prefer-to-have-been-called-once.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../prefer-to-have-been-called-once'; 2 | import { FlatCompatRuleTester as RuleTester } from './test-utils'; 3 | 4 | const ruleTester = new RuleTester(); 5 | 6 | ruleTester.run('prefer-to-have-been-called-once', rule, { 7 | valid: [ 8 | '[].push(true)', 9 | 'expect("something");', 10 | 'expect(xyz).toBe', 11 | 'expect(xyz).toBe(true)', 12 | 'expect(xyz).toHaveBeenCalled(true)', 13 | 'expect(xyz).not.toHaveBeenCalled(true)', 14 | 'expect(xyz).toHaveBeenCalled(1)', 15 | 'expect(xyz).not.toHaveBeenCalled(1)', 16 | 'expect(xyz).toHaveBeenCalledWith(1)', 17 | 'expect(xyz).not.toHaveBeenCalledWith(1)', 18 | 'expect(xyz).toHaveBeenCalledTimes(0)', 19 | 'expect(xyz).toHaveBeenCalledTimes(2)', 20 | { 21 | code: 'expect(xyz).toHaveBeenCalledTimes(...1)', 22 | parserOptions: { 23 | ecmaVersion: 2015, 24 | }, 25 | }, 26 | ], 27 | invalid: [ 28 | { 29 | code: 'expect(xyz).toHaveBeenCalledTimes(1)', 30 | output: 'expect(xyz).toHaveBeenCalledOnce()', 31 | errors: [{ messageId: 'preferCalledOnce', column: 13, line: 1 }], 32 | }, 33 | { 34 | code: 'expect(xyz).not.toHaveBeenCalledTimes(1)', 35 | output: 'expect(xyz).not.toHaveBeenCalledOnce()', 36 | errors: [{ messageId: 'preferCalledOnce', column: 17, line: 1 }], 37 | }, 38 | ], 39 | }); 40 | 41 | new RuleTester({ 42 | parser: require.resolve('@typescript-eslint/parser'), 43 | }).run('prefer-to-have-been-called-once: typescript edition', rule, { 44 | valid: [ 45 | "(expect('Model must be bound to an array if the multiple property is true') as any).toHaveBeenCalledTimes(2 as string)", 46 | ], 47 | invalid: [ 48 | { 49 | code: 'expect(xyz).toHaveBeenCalledTimes(1 as unknown as string as unknown as any);', 50 | output: 'expect(xyz).toHaveBeenCalledOnce();', 51 | errors: [{ messageId: 'preferCalledOnce', column: 13, line: 1 }], 52 | }, 53 | { 54 | code: 'expect(xyz).not.toHaveBeenCalledTimes(1 as number);', 55 | output: 'expect(xyz).not.toHaveBeenCalledOnce();', 56 | errors: [{ messageId: 'preferCalledOnce', column: 17, line: 1 }], 57 | }, 58 | ], 59 | }); 60 | -------------------------------------------------------------------------------- /src/rules/__tests__/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'module'; 2 | import { TSESLint } from '@typescript-eslint/utils'; 3 | import { version as eslintVersion } from 'eslint/package.json'; 4 | import * as semver from 'semver'; 5 | 6 | const require = createRequire(__filename); 7 | const eslintRequire = createRequire(require.resolve('eslint')); 8 | 9 | export const espreeParser = eslintRequire.resolve('espree'); 10 | 11 | export const usingFlatConfig = semver.major(eslintVersion) >= 9; 12 | 13 | export class FlatCompatRuleTester extends TSESLint.RuleTester { 14 | public constructor(testerConfig?: TSESLint.RuleTesterConfig) { 15 | super(FlatCompatRuleTester._flatCompat(testerConfig)); 16 | } 17 | 18 | public override run< 19 | TMessageIds extends string, 20 | TOptions extends readonly unknown[], 21 | >( 22 | ruleName: string, 23 | rule: TSESLint.RuleModule, 24 | tests: TSESLint.RunTests, 25 | ) { 26 | super.run(ruleName, rule, { 27 | valid: tests.valid.map(t => FlatCompatRuleTester._flatCompat(t)), 28 | invalid: tests.invalid.map(t => FlatCompatRuleTester._flatCompat(t)), 29 | }); 30 | } 31 | 32 | /* istanbul ignore next */ 33 | private static _flatCompat< 34 | T extends 35 | | undefined 36 | | RuleTesterConfig 37 | | string 38 | | TSESLint.ValidTestCase 39 | | TSESLint.InvalidTestCase, 40 | >(config: T): T { 41 | if (!config || !usingFlatConfig || typeof config === 'string') { 42 | return config; 43 | } 44 | 45 | const obj: FlatConfig.Config & { 46 | languageOptions: FlatConfig.LanguageOptions & { 47 | parserOptions: FlatConfig.ParserOptions; 48 | }; 49 | } = { 50 | languageOptions: { parserOptions: {} }, 51 | }; 52 | 53 | for (const [key, value] of Object.entries(config)) { 54 | if (key === 'parser') { 55 | obj.languageOptions.parser = require(value); 56 | 57 | continue; 58 | } 59 | 60 | if (key === 'parserOptions') { 61 | for (const [option, val] of Object.entries(value)) { 62 | if (option === 'ecmaVersion' || option === 'sourceType') { 63 | // @ts-expect-error: TS thinks the value could the opposite type of whatever option is 64 | obj.languageOptions[option] = val as FlatConfig.LanguageOptions[ 65 | | 'ecmaVersion' 66 | | 'sourceType']; 67 | 68 | continue; 69 | } 70 | 71 | obj.languageOptions.parserOptions[option] = val; 72 | } 73 | 74 | continue; 75 | } 76 | 77 | obj[key as keyof typeof obj] = value; 78 | } 79 | 80 | return obj as unknown as T; 81 | } 82 | } 83 | 84 | type RuleTesterConfig = TSESLint.RuleTesterConfig | FlatConfig.Config; 85 | 86 | export declare namespace FlatConfig { 87 | type EcmaVersion = TSESLint.EcmaVersion; 88 | type ParserOptions = TSESLint.ParserOptions; 89 | type SourceType = TSESLint.SourceType | 'commonjs'; 90 | interface LanguageOptions { 91 | /** 92 | * The version of ECMAScript to support. 93 | * May be any year (i.e., `2022`) or version (i.e., `5`). 94 | * Set to `"latest"` for the most recent supported version. 95 | * @default "latest" 96 | */ 97 | ecmaVersion?: EcmaVersion; 98 | /** 99 | * An object specifying additional objects that should be added to the global scope during linting. 100 | */ 101 | globals?: unknown; 102 | /** 103 | * An object containing a `parse()` method or a `parseForESLint()` method. 104 | * @default 105 | * ``` 106 | * // https://github.com/eslint/espree 107 | * require('espree') 108 | * ``` 109 | */ 110 | parser?: unknown; 111 | /** 112 | * An object specifying additional options that are passed directly to the parser. 113 | * The available options are parser-dependent. 114 | */ 115 | parserOptions?: ParserOptions; 116 | /** 117 | * The type of JavaScript source code. 118 | * Possible values are `"script"` for traditional script files, `"module"` for ECMAScript modules (ESM), and `"commonjs"` for CommonJS files. 119 | * @default 120 | * ``` 121 | * // for `.js` and `.mjs` files 122 | * "module" 123 | * // for `.cjs` files 124 | * "commonjs" 125 | * ``` 126 | */ 127 | sourceType?: SourceType; 128 | } 129 | interface Config { 130 | /** 131 | * An array of glob patterns indicating the files that the configuration object should apply to. 132 | * If not specified, the configuration object applies to all files matched by any other configuration object. 133 | */ 134 | files?: string[]; 135 | /** 136 | * An array of glob patterns indicating the files that the configuration object should not apply to. 137 | * If not specified, the configuration object applies to all files matched by files. 138 | */ 139 | ignores?: string[]; 140 | /** 141 | * An object containing settings related to how JavaScript is configured for linting. 142 | */ 143 | languageOptions?: LanguageOptions; 144 | /** 145 | * An object containing settings related to the linting process. 146 | */ 147 | linterOptions?: unknown; 148 | /** 149 | * An object containing a name-value mapping of plugin names to plugin objects. 150 | * When `files` is specified, these plugins are only available to the matching files. 151 | */ 152 | plugins?: unknown; 153 | /** 154 | * An object containing the configured rules. 155 | * When `files` or `ignores` are specified, these rule configurations are only available to the matching files. 156 | */ 157 | rules?: unknown; 158 | /** 159 | * An object containing name-value pairs of information that should be available to all rules. 160 | */ 161 | settings?: unknown; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/rules/prefer-to-be-array.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; 2 | import { 3 | createRule, 4 | followTypeAssertionChain, 5 | getAccessorValue, 6 | isBooleanEqualityMatcher, 7 | isInstanceOfBinaryExpression, 8 | isParsedInstanceOfMatcherCall, 9 | isSupportedAccessor, 10 | parseJestFnCall, 11 | } from './utils'; 12 | 13 | const isArrayIsArrayCall = ( 14 | node: TSESTree.Node, 15 | ): node is TSESTree.CallExpression => 16 | node.type === AST_NODE_TYPES.CallExpression && 17 | node.callee.type === AST_NODE_TYPES.MemberExpression && 18 | isSupportedAccessor(node.callee.object, 'Array') && 19 | isSupportedAccessor(node.callee.property, 'isArray'); 20 | 21 | export type MessageIds = 'preferToBeArray'; 22 | export type Options = []; 23 | 24 | export default createRule({ 25 | name: __filename, 26 | meta: { 27 | docs: { 28 | description: 'Suggest using `toBeArray()`', 29 | }, 30 | messages: { 31 | preferToBeArray: 32 | 'Prefer using `toBeArray()` to test if a value is an array.', 33 | }, 34 | fixable: 'code', 35 | type: 'suggestion', 36 | schema: [], 37 | }, 38 | defaultOptions: [], 39 | create(context) { 40 | return { 41 | CallExpression(node) { 42 | const jestFnCall = parseJestFnCall(node, context); 43 | 44 | if (jestFnCall?.type !== 'expect') { 45 | return; 46 | } 47 | 48 | if (isParsedInstanceOfMatcherCall(jestFnCall, 'Array')) { 49 | context.report({ 50 | node: jestFnCall.matcher, 51 | messageId: 'preferToBeArray', 52 | fix: fixer => [ 53 | fixer.replaceTextRange( 54 | [ 55 | jestFnCall.matcher.range[0], 56 | jestFnCall.matcher.range[1] + '(Array)'.length, 57 | ], 58 | 'toBeArray()', 59 | ), 60 | ], 61 | }); 62 | 63 | return; 64 | } 65 | 66 | const { parent: expect } = jestFnCall.head.node; 67 | 68 | if (expect?.type !== AST_NODE_TYPES.CallExpression) { 69 | return; 70 | } 71 | 72 | const [expectArg] = expect.arguments; 73 | 74 | if ( 75 | !expectArg || 76 | !isBooleanEqualityMatcher(jestFnCall) || 77 | !( 78 | isArrayIsArrayCall(expectArg) || 79 | isInstanceOfBinaryExpression(expectArg, 'Array') 80 | ) 81 | ) { 82 | return; 83 | } 84 | 85 | context.report({ 86 | node: jestFnCall.matcher, 87 | messageId: 'preferToBeArray', 88 | fix(fixer) { 89 | const fixes = [ 90 | fixer.replaceText(jestFnCall.matcher, 'toBeArray'), 91 | expectArg.type === AST_NODE_TYPES.CallExpression 92 | ? fixer.remove(expectArg.callee) 93 | : fixer.removeRange([ 94 | expectArg.left.range[1], 95 | expectArg.range[1], 96 | ]), 97 | ]; 98 | 99 | let invertCondition = 100 | getAccessorValue(jestFnCall.matcher) === 'toBeFalse'; 101 | 102 | if (jestFnCall.args.length) { 103 | const [matcherArg] = jestFnCall.args; 104 | 105 | fixes.push(fixer.remove(matcherArg)); 106 | 107 | // toBeFalse can't have arguments, so this won't be true beforehand 108 | invertCondition = 109 | matcherArg.type === AST_NODE_TYPES.Literal && 110 | followTypeAssertionChain(matcherArg).value === false; 111 | } 112 | 113 | if (invertCondition) { 114 | const notModifier = jestFnCall.modifiers.find( 115 | nod => getAccessorValue(nod) === 'not', 116 | ); 117 | 118 | fixes.push( 119 | notModifier 120 | ? fixer.removeRange([ 121 | notModifier.range[0] - 1, 122 | notModifier.range[1], 123 | ]) 124 | : fixer.insertTextBefore(jestFnCall.matcher, 'not.'), 125 | ); 126 | } 127 | 128 | return fixes; 129 | }, 130 | }); 131 | }, 132 | }; 133 | }, 134 | }); 135 | -------------------------------------------------------------------------------- /src/rules/prefer-to-be-false.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; 2 | import { 3 | EqualityMatcher, 4 | createRule, 5 | getAccessorValue, 6 | getFirstMatcherArg, 7 | parseJestFnCall, 8 | } from './utils'; 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 createRule({ 18 | name: __filename, 19 | meta: { 20 | docs: { 21 | description: 'Suggest using `toBeFalse()`', 22 | }, 23 | messages: { 24 | preferToBeFalse: 'Prefer using `toBeFalse()` to test value is `false`.', 25 | }, 26 | fixable: 'code', 27 | type: 'suggestion', 28 | schema: [], 29 | }, 30 | defaultOptions: [], 31 | create(context) { 32 | return { 33 | CallExpression(node) { 34 | const jestFnCall = parseJestFnCall(node, context); 35 | 36 | if (jestFnCall?.type !== 'expect') { 37 | return; 38 | } 39 | 40 | if ( 41 | jestFnCall.args.length === 1 && 42 | isFalseLiteral(getFirstMatcherArg(jestFnCall)) && 43 | EqualityMatcher.hasOwnProperty(getAccessorValue(jestFnCall.matcher)) 44 | ) { 45 | context.report({ 46 | node: jestFnCall.matcher, 47 | messageId: 'preferToBeFalse', 48 | fix: fixer => [ 49 | fixer.replaceText(jestFnCall.matcher, 'toBeFalse'), 50 | fixer.remove(jestFnCall.args[0]), 51 | ], 52 | }); 53 | } 54 | }, 55 | }; 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /src/rules/prefer-to-be-object.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils'; 2 | import { 3 | createRule, 4 | followTypeAssertionChain, 5 | getAccessorValue, 6 | isBooleanEqualityMatcher, 7 | isInstanceOfBinaryExpression, 8 | isParsedInstanceOfMatcherCall, 9 | parseJestFnCall, 10 | } from './utils'; 11 | 12 | export type MessageIds = 'preferToBeObject'; 13 | export type Options = []; 14 | 15 | export default createRule({ 16 | name: __filename, 17 | meta: { 18 | docs: { 19 | description: 'Suggest using `toBeObject()`', 20 | }, 21 | messages: { 22 | preferToBeObject: 23 | 'Prefer using `toBeObject()` to test if a value is an Object.', 24 | }, 25 | fixable: 'code', 26 | type: 'suggestion', 27 | schema: [], 28 | }, 29 | defaultOptions: [], 30 | create(context) { 31 | return { 32 | CallExpression(node) { 33 | const jestFnCall = parseJestFnCall(node, context); 34 | 35 | if (jestFnCall?.type !== 'expect') { 36 | return; 37 | } 38 | 39 | if (isParsedInstanceOfMatcherCall(jestFnCall, 'Object')) { 40 | context.report({ 41 | node: jestFnCall.matcher, 42 | messageId: 'preferToBeObject', 43 | fix: fixer => [ 44 | fixer.replaceTextRange( 45 | [ 46 | jestFnCall.matcher.range[0], 47 | jestFnCall.matcher.range[1] + '(Object)'.length, 48 | ], 49 | 'toBeObject()', 50 | ), 51 | ], 52 | }); 53 | 54 | return; 55 | } 56 | 57 | const { parent: expect } = jestFnCall.head.node; 58 | 59 | if (expect?.type !== AST_NODE_TYPES.CallExpression) { 60 | return; 61 | } 62 | 63 | const [expectArg] = expect.arguments; 64 | 65 | if ( 66 | !expectArg || 67 | !isBooleanEqualityMatcher(jestFnCall) || 68 | !isInstanceOfBinaryExpression(expectArg, 'Object') 69 | ) { 70 | return; 71 | } 72 | 73 | context.report({ 74 | node: jestFnCall.matcher, 75 | messageId: 'preferToBeObject', 76 | fix(fixer) { 77 | const fixes = [ 78 | fixer.replaceText(jestFnCall.matcher, 'toBeObject'), 79 | fixer.removeRange([expectArg.left.range[1], expectArg.range[1]]), 80 | ]; 81 | 82 | let invertCondition = 83 | getAccessorValue(jestFnCall.matcher) === 'toBeFalse'; 84 | 85 | if (jestFnCall.args?.length) { 86 | const [matcherArg] = jestFnCall.args; 87 | 88 | fixes.push(fixer.remove(matcherArg)); 89 | 90 | // toBeFalse can't have arguments, so this won't be true beforehand 91 | invertCondition = 92 | matcherArg.type === AST_NODE_TYPES.Literal && 93 | followTypeAssertionChain(matcherArg).value === false; 94 | } 95 | 96 | if (invertCondition) { 97 | const notModifier = jestFnCall.modifiers.find( 98 | nod => getAccessorValue(nod) === 'not', 99 | ); 100 | 101 | fixes.push( 102 | notModifier 103 | ? fixer.removeRange([ 104 | notModifier.range[0] - 1, 105 | notModifier.range[1], 106 | ]) 107 | : fixer.insertTextBefore(jestFnCall.matcher, 'not.'), 108 | ); 109 | } 110 | 111 | return fixes; 112 | }, 113 | }); 114 | }, 115 | }; 116 | }, 117 | }); 118 | -------------------------------------------------------------------------------- /src/rules/prefer-to-be-true.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; 2 | import { 3 | EqualityMatcher, 4 | createRule, 5 | getAccessorValue, 6 | getFirstMatcherArg, 7 | parseJestFnCall, 8 | } from './utils'; 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 createRule({ 18 | name: __filename, 19 | meta: { 20 | docs: { 21 | description: 'Suggest using `toBeTrue()`', 22 | }, 23 | messages: { 24 | preferToBeTrue: 'Prefer using `toBeTrue()` to test value is `true`.', 25 | }, 26 | fixable: 'code', 27 | type: 'suggestion', 28 | schema: [], 29 | }, 30 | defaultOptions: [], 31 | create(context) { 32 | return { 33 | CallExpression(node) { 34 | const jestFnCall = parseJestFnCall(node, context); 35 | 36 | if (jestFnCall?.type !== 'expect') { 37 | return; 38 | } 39 | 40 | if ( 41 | jestFnCall.args.length === 1 && 42 | isTrueLiteral(getFirstMatcherArg(jestFnCall)) && 43 | EqualityMatcher.hasOwnProperty(getAccessorValue(jestFnCall.matcher)) 44 | ) { 45 | context.report({ 46 | node: jestFnCall.matcher, 47 | messageId: 'preferToBeTrue', 48 | fix: fixer => [ 49 | fixer.replaceText(jestFnCall.matcher, 'toBeTrue'), 50 | fixer.remove(jestFnCall.args[0]), 51 | ], 52 | }); 53 | } 54 | }, 55 | }; 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /src/rules/prefer-to-have-been-called-once.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils'; 2 | import { 3 | createRule, 4 | getAccessorValue, 5 | getFirstMatcherArg, 6 | parseJestFnCall, 7 | } from './utils'; 8 | 9 | export default createRule({ 10 | name: __filename, 11 | meta: { 12 | docs: { 13 | description: 'Suggest using `toHaveBeenCalledOnce()`', 14 | }, 15 | messages: { 16 | preferCalledOnce: 'Prefer `toHaveBeenCalledOnce()`', 17 | }, 18 | fixable: 'code', 19 | type: 'suggestion', 20 | schema: [], 21 | }, 22 | defaultOptions: [], 23 | create(context) { 24 | return { 25 | CallExpression(node) { 26 | const jestFnCall = parseJestFnCall(node, context); 27 | 28 | if (jestFnCall?.type !== 'expect') { 29 | return; 30 | } 31 | 32 | if ( 33 | getAccessorValue(jestFnCall.matcher) === 'toHaveBeenCalledTimes' && 34 | jestFnCall.args.length === 1 35 | ) { 36 | const arg = getFirstMatcherArg(jestFnCall); 37 | 38 | if (arg.type !== AST_NODE_TYPES.Literal || arg.value !== 1) { 39 | return; 40 | } 41 | 42 | context.report({ 43 | node: jestFnCall.matcher, 44 | messageId: 'preferCalledOnce', 45 | fix: fixer => [ 46 | fixer.replaceText(jestFnCall.matcher, 'toHaveBeenCalledOnce'), 47 | fixer.remove(jestFnCall.args[0]), 48 | ], 49 | }); 50 | } 51 | }, 52 | }; 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /src/rules/utils/__tests__/parseJestFnCall.test.ts: -------------------------------------------------------------------------------- 1 | import type { JSONSchemaForNPMPackageJsonFiles } from '@schemastore/package'; 2 | import type { TSESTree } from '@typescript-eslint/utils'; 3 | import dedent from 'dedent'; 4 | import { 5 | FlatCompatRuleTester as RuleTester, 6 | espreeParser, 7 | } from '../../__tests__/test-utils'; 8 | import { 9 | type ParsedJestFnCall, 10 | type ResolvedJestFnWithNode, 11 | createRule, 12 | getAccessorValue, 13 | isSupportedAccessor, 14 | parseJestFnCall, 15 | } from '../../utils'; 16 | 17 | const findESLintVersion = (): number => { 18 | const eslintPath = require.resolve('eslint/package.json'); 19 | 20 | const eslintPackageJson = 21 | // eslint-disable-next-line @typescript-eslint/no-require-imports 22 | require(eslintPath) as JSONSchemaForNPMPackageJsonFiles; 23 | 24 | if (!eslintPackageJson.version) { 25 | throw new Error('eslint package.json does not have a version!'); 26 | } 27 | 28 | const [majorVersion] = eslintPackageJson.version.split('.'); 29 | 30 | return parseInt(majorVersion, 10); 31 | }; 32 | 33 | const eslintVersion = findESLintVersion(); 34 | 35 | const ruleTester = new RuleTester({ 36 | parser: espreeParser, 37 | parserOptions: { 38 | ecmaVersion: 2015, 39 | }, 40 | }); 41 | 42 | const isNode = (obj: unknown): obj is TSESTree.Node => { 43 | if (typeof obj === 'object' && obj !== null) { 44 | return ['type', 'loc', 'range', 'parent'].every(p => p in obj); 45 | } 46 | 47 | return false; 48 | }; 49 | 50 | const rule = createRule({ 51 | name: __filename, 52 | meta: { 53 | docs: { 54 | description: 'Fake rule for testing parseJestFnCall', 55 | }, 56 | messages: { 57 | details: '{{ data }}', 58 | }, 59 | schema: [], 60 | type: 'problem', 61 | }, 62 | defaultOptions: [], 63 | create: context => ({ 64 | CallExpression(node) { 65 | const jestFnCall = parseJestFnCall(node, context); 66 | 67 | if (jestFnCall) { 68 | const sorted = { 69 | // ...jestFnCall, 70 | name: jestFnCall.name, 71 | type: jestFnCall.type, 72 | head: jestFnCall.head, 73 | members: jestFnCall.members, 74 | }; 75 | 76 | context.report({ 77 | messageId: 'details', 78 | node, 79 | data: { 80 | data: JSON.stringify(sorted, (_key, value) => { 81 | if (isNode(value)) { 82 | if (isSupportedAccessor(value)) { 83 | return getAccessorValue(value); 84 | } 85 | 86 | return undefined; 87 | } 88 | 89 | return value; 90 | }), 91 | }, 92 | }); 93 | } 94 | }, 95 | }), 96 | }); 97 | 98 | interface TestResolvedJestFnWithNode 99 | extends Omit { 100 | node: string; 101 | } 102 | 103 | interface TestParsedJestFnCall 104 | extends Omit { 105 | head: TestResolvedJestFnWithNode; 106 | members: string[]; 107 | } 108 | 109 | // const sortParsedJestFnCallResults = () 110 | 111 | const expectedParsedJestFnCallResultData = (result: TestParsedJestFnCall) => ({ 112 | data: JSON.stringify({ 113 | name: result.name, 114 | type: result.type, 115 | head: result.head, 116 | members: result.members, 117 | }), 118 | }); 119 | 120 | ruleTester.run('nonexistent methods', rule, { 121 | valid: [ 122 | 'describe.something()', 123 | 'describe.me()', 124 | 'test.me()', 125 | 'it.fails()', 126 | 'context()', 127 | 'context.each``()', 128 | 'context.each()', 129 | 'describe.context()', 130 | 'describe.concurrent()()', 131 | 'describe.concurrent``()', 132 | 'describe.every``()', 133 | '/regex/.test()', 134 | '"something".describe()', 135 | '[].describe()', 136 | 'new describe().only()', 137 | '``.test()', 138 | 'test.only``()', 139 | 'test``.only()', 140 | ], 141 | invalid: [], 142 | }); 143 | 144 | ruleTester.run('expect', rule, { 145 | valid: [ 146 | { 147 | code: dedent` 148 | import { expect } from './test-utils'; 149 | 150 | expect(x).toBe(y); 151 | `, 152 | parserOptions: { sourceType: 'module' }, 153 | }, 154 | { 155 | code: dedent` 156 | import { expect } from '@jest/globals'; 157 | 158 | expect(x).not.resolves.toBe(x); 159 | `, 160 | parserOptions: { sourceType: 'module' }, 161 | }, 162 | // { 163 | // code: dedent` 164 | // import { expect } from '@jest/globals'; 165 | // 166 | // expect(x).not().toBe(x); 167 | // `, 168 | // parserOptions: { sourceType: 'module' }, 169 | // }, 170 | { 171 | code: dedent` 172 | import { expect } from '@jest/globals'; 173 | 174 | expect(x).is.toBe(x); 175 | `, 176 | parserOptions: { sourceType: 'module' }, 177 | }, 178 | { 179 | code: dedent` 180 | import { expect } from '@jest/globals'; 181 | 182 | expect; 183 | expect(x); 184 | expect(x).toBe; 185 | expect(x).not.toBe; 186 | //expect(x).toBe(x).not(); 187 | `, 188 | parserOptions: { sourceType: 'module' }, 189 | }, 190 | ], 191 | invalid: [ 192 | { 193 | code: 'expect(x).toBe(y);', 194 | parserOptions: { sourceType: 'script' }, 195 | errors: [ 196 | { 197 | messageId: 'details' as const, 198 | data: expectedParsedJestFnCallResultData({ 199 | name: 'expect', 200 | type: 'expect', 201 | head: { 202 | original: null, 203 | local: 'expect', 204 | type: 'global', 205 | node: 'expect', 206 | }, 207 | members: ['toBe'], 208 | }), 209 | column: 1, 210 | line: 1, 211 | }, 212 | ], 213 | }, 214 | { 215 | code: dedent` 216 | import { expect } from '@jest/globals'; 217 | 218 | expect.assertions(); 219 | `, 220 | parserOptions: { sourceType: 'module' }, 221 | errors: [ 222 | { 223 | messageId: 'details' as const, 224 | data: expectedParsedJestFnCallResultData({ 225 | name: 'expect', 226 | type: 'expect', 227 | head: { 228 | original: 'expect', 229 | local: 'expect', 230 | type: 'import', 231 | node: 'expect', 232 | }, 233 | members: ['assertions'], 234 | }), 235 | column: 1, 236 | line: 3, 237 | }, 238 | ], 239 | }, 240 | { 241 | code: dedent` 242 | import { expect } from '@jest/globals'; 243 | 244 | expect(x).toBe(y); 245 | `, 246 | parserOptions: { sourceType: 'module' }, 247 | errors: [ 248 | { 249 | messageId: 'details' as const, 250 | data: expectedParsedJestFnCallResultData({ 251 | name: 'expect', 252 | type: 'expect', 253 | head: { 254 | original: 'expect', 255 | local: 'expect', 256 | type: 'import', 257 | node: 'expect', 258 | }, 259 | members: ['toBe'], 260 | }), 261 | column: 1, 262 | line: 3, 263 | }, 264 | ], 265 | }, 266 | { 267 | code: dedent` 268 | import { expect } from '@jest/globals'; 269 | 270 | expect(x).not(y); 271 | `, 272 | parserOptions: { sourceType: 'module' }, 273 | errors: [ 274 | { 275 | messageId: 'details' as const, 276 | data: expectedParsedJestFnCallResultData({ 277 | name: 'expect', 278 | type: 'expect', 279 | head: { 280 | original: 'expect', 281 | local: 'expect', 282 | type: 'import', 283 | node: 'expect', 284 | }, 285 | members: ['not'], 286 | }), 287 | column: 1, 288 | line: 3, 289 | }, 290 | ], 291 | }, 292 | { 293 | code: dedent` 294 | import { expect } from '@jest/globals'; 295 | 296 | expect(x).not.toBe(y); 297 | `, 298 | parserOptions: { sourceType: 'module' }, 299 | errors: [ 300 | { 301 | messageId: 'details' as const, 302 | data: expectedParsedJestFnCallResultData({ 303 | name: 'expect', 304 | type: 'expect', 305 | head: { 306 | original: 'expect', 307 | local: 'expect', 308 | type: 'import', 309 | node: 'expect', 310 | }, 311 | members: ['not', 'toBe'], 312 | }), 313 | column: 1, 314 | line: 3, 315 | }, 316 | ], 317 | }, 318 | { 319 | code: dedent` 320 | import { expect } from '@jest/globals'; 321 | 322 | expect.assertions(); 323 | expect.hasAssertions(); 324 | expect.anything(); 325 | expect.not.arrayContaining(); 326 | `, 327 | parserOptions: { sourceType: 'module' }, 328 | errors: [ 329 | { 330 | messageId: 'details' as const, 331 | data: expectedParsedJestFnCallResultData({ 332 | name: 'expect', 333 | type: 'expect', 334 | head: { 335 | original: 'expect', 336 | local: 'expect', 337 | type: 'import', 338 | node: 'expect', 339 | }, 340 | members: ['assertions'], 341 | }), 342 | column: 1, 343 | line: 3, 344 | }, 345 | { 346 | messageId: 'details' as const, 347 | data: expectedParsedJestFnCallResultData({ 348 | name: 'expect', 349 | type: 'expect', 350 | head: { 351 | original: 'expect', 352 | local: 'expect', 353 | type: 'import', 354 | node: 'expect', 355 | }, 356 | members: ['hasAssertions'], 357 | }), 358 | column: 1, 359 | line: 4, 360 | }, 361 | { 362 | messageId: 'details' as const, 363 | data: expectedParsedJestFnCallResultData({ 364 | name: 'expect', 365 | type: 'expect', 366 | head: { 367 | original: 'expect', 368 | local: 'expect', 369 | type: 'import', 370 | node: 'expect', 371 | }, 372 | members: ['anything'], 373 | }), 374 | column: 1, 375 | line: 5, 376 | }, 377 | { 378 | messageId: 'details' as const, 379 | data: expectedParsedJestFnCallResultData({ 380 | name: 'expect', 381 | type: 'expect', 382 | head: { 383 | original: 'expect', 384 | local: 'expect', 385 | type: 'import', 386 | node: 'expect', 387 | }, 388 | members: ['not', 'arrayContaining'], 389 | }), 390 | column: 1, 391 | line: 6, 392 | }, 393 | ], 394 | }, 395 | ], 396 | }); 397 | 398 | ruleTester.run('esm', rule, { 399 | valid: [ 400 | { 401 | code: dedent` 402 | import { it } from './test-utils'; 403 | 404 | it('is not a jest function', () => {}); 405 | `, 406 | parserOptions: { sourceType: 'module' }, 407 | }, 408 | { 409 | code: dedent` 410 | import { defineFeature, loadFeature } from "jest-cucumber"; 411 | 412 | const feature = loadFeature("some/feature"); 413 | 414 | defineFeature(feature, (test) => { 415 | test("A scenario", ({ given, when, then }) => {}); 416 | }); 417 | `, 418 | parserOptions: { sourceType: 'module' }, 419 | }, 420 | { 421 | code: dedent` 422 | import { describe } from './test-utils'; 423 | 424 | describe('a function that is not from jest', () => {}); 425 | `, 426 | parserOptions: { sourceType: 'module' }, 427 | }, 428 | { 429 | code: dedent` 430 | import { fn as it } from './test-utils'; 431 | 432 | it('is not a jest function', () => {}); 433 | `, 434 | parserOptions: { sourceType: 'module' }, 435 | }, 436 | { 437 | code: dedent` 438 | import * as jest from '@jest/globals'; 439 | const { it } = jest; 440 | 441 | it('is not supported', () => {}); 442 | `, 443 | parserOptions: { sourceType: 'module' }, 444 | }, 445 | { 446 | code: dedent` 447 | import ByDefault from './myfile'; 448 | 449 | ByDefault.sayHello(); 450 | `, 451 | parserOptions: { sourceType: 'module' }, 452 | }, 453 | { 454 | code: dedent` 455 | async function doSomething() { 456 | const build = await rollup(config); 457 | build.generate(); 458 | } 459 | `, 460 | parserOptions: { sourceType: 'module', ecmaVersion: 2017 }, 461 | }, 462 | ], 463 | invalid: [], 464 | }); 465 | 466 | if (eslintVersion >= 8) { 467 | ruleTester.run('esm (dynamic)', rule, { 468 | valid: [ 469 | { 470 | code: dedent` 471 | const { it } = await import('./test-utils'); 472 | 473 | it('is not a jest function', () => {}); 474 | `, 475 | parserOptions: { sourceType: 'module', ecmaVersion: 2022 }, 476 | }, 477 | { 478 | code: dedent` 479 | const { it } = await import(\`./test-utils\`); 480 | 481 | it('is not a jest function', () => {}); 482 | `, 483 | parserOptions: { sourceType: 'module', ecmaVersion: 2022 }, 484 | }, 485 | ], 486 | invalid: [ 487 | { 488 | code: dedent` 489 | const { it } = await import("@jest/globals"); 490 | 491 | it('is a jest function', () => {}); 492 | `, 493 | parserOptions: { sourceType: 'module', ecmaVersion: 2022 }, 494 | errors: [ 495 | { 496 | messageId: 'details' as const, 497 | data: expectedParsedJestFnCallResultData({ 498 | name: 'it', 499 | type: 'test', 500 | head: { 501 | original: 'it', 502 | local: 'it', 503 | type: 'import', 504 | node: 'it', 505 | }, 506 | members: [], 507 | }), 508 | column: 1, 509 | line: 3, 510 | }, 511 | ], 512 | }, 513 | { 514 | code: dedent` 515 | const { it } = await import(\`@jest/globals\`); 516 | 517 | it('is a jest function', () => {}); 518 | `, 519 | parserOptions: { sourceType: 'module', ecmaVersion: 2022 }, 520 | errors: [ 521 | { 522 | messageId: 'details' as const, 523 | data: expectedParsedJestFnCallResultData({ 524 | name: 'it', 525 | type: 'test', 526 | head: { 527 | original: 'it', 528 | local: 'it', 529 | type: 'import', 530 | node: 'it', 531 | }, 532 | members: [], 533 | }), 534 | column: 1, 535 | line: 3, 536 | }, 537 | ], 538 | }, 539 | ], 540 | }); 541 | } 542 | 543 | ruleTester.run('cjs', rule, { 544 | valid: [ 545 | { 546 | code: dedent` 547 | const { it } = require('./test-utils'); 548 | 549 | it('is not a jest function', () => {}); 550 | `, 551 | parserOptions: { sourceType: 'script' }, 552 | }, 553 | { 554 | code: dedent` 555 | const { it } = require(\`./test-utils\`); 556 | 557 | it('is not a jest function', () => {}); 558 | `, 559 | parserOptions: { sourceType: 'script' }, 560 | }, 561 | { 562 | code: dedent` 563 | const { describe } = require('./test-utils'); 564 | 565 | describe('a function that is not from jest', () => {}); 566 | `, 567 | parserOptions: { sourceType: 'script' }, 568 | }, 569 | { 570 | code: dedent` 571 | const { fn: it } = require('./test-utils'); 572 | 573 | it('is not a jest function', () => {}); 574 | `, 575 | parserOptions: { sourceType: 'script' }, 576 | }, 577 | { 578 | code: dedent` 579 | const { fn: it } = require('@jest/globals'); 580 | 581 | it('is not considered a test function', () => {}); 582 | `, 583 | parserOptions: { sourceType: 'script' }, 584 | }, 585 | { 586 | code: dedent` 587 | const { it } = aliasedRequire('@jest/globals'); 588 | 589 | it('is not considered a jest function', () => {}); 590 | `, 591 | parserOptions: { sourceType: 'script' }, 592 | }, 593 | { 594 | code: dedent` 595 | const { it } = require(); 596 | 597 | it('is not a jest function', () => {}); 598 | `, 599 | parserOptions: { sourceType: 'script' }, 600 | }, 601 | { 602 | code: dedent` 603 | const { it } = require(pathToMyPackage); 604 | 605 | it('is not a jest function', () => {}); 606 | `, 607 | parserOptions: { sourceType: 'script' }, 608 | }, 609 | { 610 | code: dedent` 611 | const { [() => {}]: it } = require('@jest/globals'); 612 | 613 | it('is not a jest function', () => {}); 614 | `, 615 | parserOptions: { sourceType: 'script' }, 616 | }, 617 | ], 618 | invalid: [], 619 | }); 620 | 621 | ruleTester.run('global aliases', rule, { 622 | valid: [ 623 | { 624 | code: 'xcontext("skip this please", () => {});', 625 | settings: { jest: { globalAliases: { describe: ['context'] } } }, 626 | }, 627 | ], 628 | invalid: [ 629 | { 630 | code: 'context("when there is an error", () => {})', 631 | errors: [ 632 | { 633 | messageId: 'details' as const, 634 | data: expectedParsedJestFnCallResultData({ 635 | name: 'describe', 636 | type: 'describe', 637 | head: { 638 | original: 'describe', 639 | local: 'context', 640 | type: 'global', 641 | node: 'context', 642 | }, 643 | members: [], 644 | }), 645 | column: 1, 646 | line: 1, 647 | }, 648 | ], 649 | settings: { jest: { globalAliases: { describe: ['context'] } } }, 650 | }, 651 | { 652 | code: 'context.skip("when there is an error", () => {})', 653 | errors: [ 654 | { 655 | messageId: 'details' as const, 656 | data: expectedParsedJestFnCallResultData({ 657 | name: 'describe', 658 | type: 'describe', 659 | head: { 660 | original: 'describe', 661 | local: 'context', 662 | type: 'global', 663 | node: 'context', 664 | }, 665 | members: ['skip'], 666 | }), 667 | column: 1, 668 | line: 1, 669 | }, 670 | ], 671 | settings: { jest: { globalAliases: { describe: ['context'] } } }, 672 | }, 673 | { 674 | code: dedent` 675 | context("when there is an error", () => {}) 676 | xcontext("skip this please", () => {}); 677 | `, 678 | errors: [ 679 | { 680 | messageId: 'details' as const, 681 | data: expectedParsedJestFnCallResultData({ 682 | name: 'xdescribe', 683 | type: 'describe', 684 | head: { 685 | original: 'xdescribe', 686 | local: 'xcontext', 687 | type: 'global', 688 | node: 'xcontext', 689 | }, 690 | members: [], 691 | }), 692 | column: 1, 693 | line: 2, 694 | }, 695 | ], 696 | settings: { jest: { globalAliases: { xdescribe: ['xcontext'] } } }, 697 | }, 698 | { 699 | code: dedent` 700 | context("when there is an error", () => {}) 701 | describe("when there is an error", () => {}) 702 | xcontext("skip this please", () => {}); 703 | `, 704 | errors: [ 705 | { 706 | messageId: 'details' as const, 707 | data: expectedParsedJestFnCallResultData({ 708 | name: 'describe', 709 | type: 'describe', 710 | head: { 711 | original: 'describe', 712 | local: 'context', 713 | type: 'global', 714 | node: 'context', 715 | }, 716 | members: [], 717 | }), 718 | column: 1, 719 | line: 1, 720 | }, 721 | { 722 | messageId: 'details' as const, 723 | data: expectedParsedJestFnCallResultData({ 724 | name: 'describe', 725 | type: 'describe', 726 | head: { 727 | original: null, 728 | local: 'describe', 729 | type: 'global', 730 | node: 'describe', 731 | }, 732 | members: [], 733 | }), 734 | column: 1, 735 | line: 2, 736 | }, 737 | ], 738 | settings: { jest: { globalAliases: { describe: ['context'] } } }, 739 | }, 740 | ], 741 | }); 742 | 743 | ruleTester.run('global package source', rule, { 744 | valid: [ 745 | { 746 | code: dedent` 747 | import { expect } from 'bun:test' 748 | 749 | expect(x).toBe(y); 750 | `, 751 | parserOptions: { sourceType: 'module' }, 752 | settings: { jest: { globalPackage: '@jest/globals' } }, 753 | }, 754 | { 755 | code: dedent` 756 | const { it } = require('@jest/globals'); 757 | 758 | it('is not considered a test function', () => {}); 759 | `, 760 | parserOptions: { sourceType: 'script' }, 761 | settings: { jest: { globalPackage: 'bun:test' } }, 762 | }, 763 | { 764 | code: dedent` 765 | const { fn: it } = require('bun:test'); 766 | 767 | it('is not considered a test function', () => {}); 768 | `, 769 | parserOptions: { sourceType: 'script' }, 770 | settings: { jest: { globalPackage: 'bun:test' } }, 771 | }, 772 | { 773 | code: dedent` 774 | import { it } from '@jest/globals'; 775 | 776 | it('is not considered a test function', () => {}); 777 | `, 778 | parserOptions: { sourceType: 'module' }, 779 | settings: { jest: { globalPackage: 'bun:test' } }, 780 | }, 781 | { 782 | code: dedent` 783 | import { fn as it } from 'bun:test'; 784 | 785 | it('is not considered a test function', () => {}); 786 | `, 787 | parserOptions: { sourceType: 'module' }, 788 | settings: { jest: { globalPackage: 'bun:test' } }, 789 | }, 790 | ], 791 | invalid: [ 792 | { 793 | code: 'expect(x).toBe(y);', 794 | parserOptions: { sourceType: 'script' }, 795 | errors: [ 796 | { 797 | messageId: 'details' as const, 798 | data: expectedParsedJestFnCallResultData({ 799 | name: 'expect', 800 | type: 'expect', 801 | head: { 802 | original: null, 803 | local: 'expect', 804 | type: 'global', 805 | node: 'expect', 806 | }, 807 | members: ['toBe'], 808 | }), 809 | column: 1, 810 | line: 1, 811 | }, 812 | ], 813 | settings: { jest: { globalPackage: 'bun:test' } }, 814 | }, 815 | { 816 | code: dedent` 817 | import { describe, expect, it } from 'bun:test' 818 | 819 | describe('some tests', () => { 820 | it('ensures something', () => { 821 | expect.assertions(); 822 | }); 823 | }); 824 | `, 825 | parserOptions: { sourceType: 'module' }, 826 | errors: [ 827 | { 828 | messageId: 'details' as const, 829 | data: expectedParsedJestFnCallResultData({ 830 | name: 'describe', 831 | type: 'describe', 832 | head: { 833 | original: 'describe', 834 | local: 'describe', 835 | type: 'import', 836 | node: 'describe', 837 | }, 838 | members: [], 839 | }), 840 | column: 1, 841 | line: 3, 842 | }, 843 | { 844 | messageId: 'details' as const, 845 | data: expectedParsedJestFnCallResultData({ 846 | name: 'it', 847 | type: 'test', 848 | head: { 849 | original: 'it', 850 | local: 'it', 851 | type: 'import', 852 | node: 'it', 853 | }, 854 | members: [], 855 | }), 856 | column: 3, 857 | line: 4, 858 | }, 859 | { 860 | messageId: 'details' as const, 861 | data: expectedParsedJestFnCallResultData({ 862 | name: 'expect', 863 | type: 'expect', 864 | head: { 865 | original: 'expect', 866 | local: 'expect', 867 | type: 'import', 868 | node: 'expect', 869 | }, 870 | members: ['assertions'], 871 | }), 872 | column: 5, 873 | line: 5, 874 | }, 875 | ], 876 | settings: { jest: { globalPackage: 'bun:test' } }, 877 | }, 878 | { 879 | code: dedent` 880 | import { expect } from 'bun:test' 881 | 882 | expect(x).not.toBe(y); 883 | `, 884 | parserOptions: { sourceType: 'module' }, 885 | errors: [ 886 | { 887 | messageId: 'details' as const, 888 | data: expectedParsedJestFnCallResultData({ 889 | name: 'expect', 890 | type: 'expect', 891 | head: { 892 | original: 'expect', 893 | local: 'expect', 894 | type: 'import', 895 | node: 'expect', 896 | }, 897 | members: ['not', 'toBe'], 898 | }), 899 | column: 1, 900 | line: 3, 901 | }, 902 | ], 903 | settings: { jest: { globalPackage: 'bun:test' } }, 904 | }, 905 | { 906 | code: 'context("when there is an error", () => {})', 907 | errors: [ 908 | { 909 | messageId: 'details' as const, 910 | data: expectedParsedJestFnCallResultData({ 911 | name: 'describe', 912 | type: 'describe', 913 | head: { 914 | original: 'describe', 915 | local: 'context', 916 | type: 'global', 917 | node: 'context', 918 | }, 919 | members: [], 920 | }), 921 | column: 1, 922 | line: 1, 923 | }, 924 | ], 925 | settings: { 926 | jest: { 927 | globalPackage: 'bun:test', 928 | globalAliases: { describe: ['context'] }, 929 | }, 930 | }, 931 | }, 932 | ], 933 | }); 934 | 935 | ruleTester.run('typescript', rule, { 936 | valid: [ 937 | { 938 | code: dedent` 939 | const { test }; 940 | 941 | test('is not a jest function', () => {}); 942 | `, 943 | parser: require.resolve('@typescript-eslint/parser'), 944 | }, 945 | { 946 | code: dedent` 947 | import type { it } from '@jest/globals'; 948 | 949 | it('is not a jest function', () => {}); 950 | `, 951 | parser: require.resolve('@typescript-eslint/parser'), 952 | parserOptions: { sourceType: 'module' }, 953 | }, 954 | { 955 | code: dedent` 956 | import jest = require('@jest/globals'); 957 | const { it } = jest; 958 | 959 | it('is not a jest function', () => {}); 960 | `, 961 | parser: require.resolve('@typescript-eslint/parser'), 962 | parserOptions: { sourceType: 'module' }, 963 | }, 964 | { 965 | code: dedent` 966 | function it(message: string, fn: () => void): void; 967 | function it(cases: unknown[], message: string, fn: () => void): void; 968 | function it(...all: any[]): void {} 969 | 970 | it('is not a jest function', () => {}); 971 | `, 972 | parser: require.resolve('@typescript-eslint/parser'), 973 | parserOptions: { sourceType: 'module' }, 974 | }, 975 | { 976 | code: dedent` 977 | interface it {} 978 | function it(...all: any[]): void {} 979 | 980 | it('is not a jest function', () => {}); 981 | `, 982 | parser: require.resolve('@typescript-eslint/parser'), 983 | parserOptions: { sourceType: 'module' }, 984 | }, 985 | { 986 | code: dedent` 987 | import { it } from '@jest/globals'; 988 | import { it } from '../it-utils'; 989 | 990 | it('is not a jest function', () => {}); 991 | `, 992 | parser: require.resolve('@typescript-eslint/parser'), 993 | parserOptions: { sourceType: 'module' }, 994 | }, 995 | { 996 | code: dedent` 997 | import dedent = require('dedent'); 998 | 999 | dedent(); 1000 | `, 1001 | parser: require.resolve('@typescript-eslint/parser'), 1002 | }, 1003 | ], 1004 | invalid: [ 1005 | { 1006 | code: dedent` 1007 | import { it } from '../it-utils'; 1008 | import { it } from '@jest/globals'; 1009 | 1010 | it('is a jest function', () => {}); 1011 | `, 1012 | parser: require.resolve('@typescript-eslint/parser'), 1013 | parserOptions: { sourceType: 'module' }, 1014 | errors: [ 1015 | { 1016 | messageId: 'details' as const, 1017 | data: expectedParsedJestFnCallResultData({ 1018 | name: 'it', 1019 | type: 'test', 1020 | head: { 1021 | original: 'it', 1022 | local: 'it', 1023 | type: 'import', 1024 | node: 'it', 1025 | }, 1026 | members: [], 1027 | }), 1028 | column: 1, 1029 | line: 4, 1030 | }, 1031 | ], 1032 | }, 1033 | ], 1034 | }); 1035 | 1036 | ruleTester.run('misc', rule, { 1037 | valid: [ 1038 | 'var spyOn = require("actions"); spyOn("foo")', 1039 | 'test().finally()', 1040 | 'expect(true).not.not.toBeDefined();', 1041 | 'expect(true).resolves.not.exactly.toBeDefined();', 1042 | ], 1043 | invalid: [ 1044 | { 1045 | code: 'beforeEach(() => {});', 1046 | errors: [ 1047 | { 1048 | messageId: 'details' as const, 1049 | data: expectedParsedJestFnCallResultData({ 1050 | name: 'beforeEach', 1051 | type: 'hook', 1052 | head: { 1053 | original: null, 1054 | local: 'beforeEach', 1055 | type: 'global', 1056 | node: 'beforeEach', 1057 | }, 1058 | members: [], 1059 | }), 1060 | column: 1, 1061 | line: 1, 1062 | }, 1063 | ], 1064 | }, 1065 | { 1066 | code: 'jest.spyOn(console, "log");', 1067 | errors: [ 1068 | { 1069 | messageId: 'details' as const, 1070 | data: expectedParsedJestFnCallResultData({ 1071 | name: 'jest', 1072 | type: 'jest', 1073 | head: { 1074 | original: null, 1075 | local: 'jest', 1076 | type: 'global', 1077 | node: 'jest', 1078 | }, 1079 | members: ['spyOn'], 1080 | }), 1081 | column: 1, 1082 | line: 1, 1083 | }, 1084 | ], 1085 | }, 1086 | { 1087 | code: dedent` 1088 | test('valid-expect-in-promise', async () => { 1089 | const text = await fetch('url') 1090 | .then(res => res.text()) 1091 | .then(text => text); 1092 | 1093 | expect(text).toBe('text'); 1094 | }); 1095 | `, 1096 | parserOptions: { ecmaVersion: 2017 }, 1097 | errors: [ 1098 | { 1099 | messageId: 'details' as const, 1100 | data: expectedParsedJestFnCallResultData({ 1101 | name: 'test', 1102 | type: 'test', 1103 | head: { 1104 | original: null, 1105 | local: 'test', 1106 | type: 'global', 1107 | node: 'test', 1108 | }, 1109 | members: [], 1110 | }), 1111 | column: 1, 1112 | line: 1, 1113 | }, 1114 | { 1115 | messageId: 'details' as const, 1116 | data: expectedParsedJestFnCallResultData({ 1117 | name: 'expect', 1118 | type: 'expect', 1119 | head: { 1120 | original: null, 1121 | local: 'expect', 1122 | type: 'global', 1123 | node: 'expect', 1124 | }, 1125 | members: ['toBe'], 1126 | }), 1127 | column: 3, 1128 | line: 6, 1129 | }, 1130 | ], 1131 | }, 1132 | ], 1133 | }); 1134 | -------------------------------------------------------------------------------- /src/rules/utils/accessors.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; 2 | 3 | /** 4 | * A `Literal` with a `value` of type `string`. 5 | */ 6 | interface StringLiteral 7 | extends TSESTree.StringLiteral { 8 | value: Value; 9 | } 10 | 11 | /** 12 | * Checks if the given `node` is a `StringLiteral`. 13 | * 14 | * If a `value` is provided & the `node` is a `StringLiteral`, 15 | * the `value` will be compared to that of the `StringLiteral`. 16 | * 17 | * @param {Node} node 18 | * @param {V} [value] 19 | * 20 | * @return {node is StringLiteral} 21 | * 22 | * @template V 23 | */ 24 | const isStringLiteral = ( 25 | node: TSESTree.Node, 26 | value?: V, 27 | ): node is StringLiteral => 28 | node.type === AST_NODE_TYPES.Literal && 29 | typeof node.value === 'string' && 30 | (value === undefined || node.value === value); 31 | 32 | interface TemplateLiteral 33 | extends TSESTree.TemplateLiteral { 34 | quasis: [TSESTree.TemplateElement & { value: { raw: Value; cooked: Value } }]; 35 | } 36 | 37 | /** 38 | * Checks if the given `node` is a `TemplateLiteral`. 39 | * 40 | * Complex `TemplateLiteral`s are not considered specific, and so will return `false`. 41 | * 42 | * If a `value` is provided & the `node` is a `TemplateLiteral`, 43 | * the `value` will be compared to that of the `TemplateLiteral`. 44 | * 45 | * @param {Node} node 46 | * @param {V} [value] 47 | * 48 | * @return {node is TemplateLiteral} 49 | * 50 | * @template V 51 | */ 52 | const isTemplateLiteral = ( 53 | node: TSESTree.Node, 54 | value?: V, 55 | ): node is TemplateLiteral => 56 | node.type === AST_NODE_TYPES.TemplateLiteral && 57 | node.quasis.length === 1 && // bail out if not simple 58 | (value === undefined || node.quasis[0].value.raw === value); 59 | 60 | export type StringNode = 61 | | StringLiteral 62 | | TemplateLiteral; 63 | 64 | /** 65 | * Checks if the given `node` is a {@link StringNode}. 66 | * 67 | * @param {Node} node 68 | * @param {V} [specifics] 69 | * 70 | * @return {node is StringNode} 71 | * 72 | * @template V 73 | */ 74 | export const isStringNode = ( 75 | node: TSESTree.Node, 76 | specifics?: V, 77 | ): node is StringNode => 78 | isStringLiteral(node, specifics) || isTemplateLiteral(node, specifics); 79 | 80 | /** 81 | * Gets the value of the given `StringNode`. 82 | * 83 | * If the `node` is a `TemplateLiteral`, the `raw` value is used; 84 | * otherwise, `value` is returned instead. 85 | * 86 | * @param {StringNode} node 87 | * 88 | * @return {S} 89 | * 90 | * @template S 91 | */ 92 | export const getStringValue = (node: StringNode): S => 93 | isTemplateLiteral(node) ? node.quasis[0].value.raw : node.value; 94 | 95 | /** 96 | * An `Identifier` with a known `name` value - i.e `expect`. 97 | */ 98 | interface KnownIdentifier extends TSESTree.Identifier { 99 | name: Name; 100 | } 101 | 102 | /** 103 | * Checks if the given `node` is an `Identifier`. 104 | * 105 | * If a `name` is provided, & the `node` is an `Identifier`, 106 | * the `name` will be compared to that of the `identifier`. 107 | * 108 | * @param {Node} node 109 | * @param {V} [name] 110 | * 111 | * @return {node is KnownIdentifier} 112 | * 113 | * @template V 114 | */ 115 | export const isIdentifier = ( 116 | node: TSESTree.Node, 117 | name?: V, 118 | ): node is KnownIdentifier => 119 | node.type === AST_NODE_TYPES.Identifier && 120 | (name === undefined || node.name === name); 121 | 122 | /** 123 | * Checks if the given `node` is a "supported accessor". 124 | * 125 | * This means that it's a node can be used to access properties, 126 | * and who's "value" can be statically determined. 127 | * 128 | * `MemberExpression` nodes most commonly contain accessors, 129 | * but it's possible for other nodes to contain them. 130 | * 131 | * If a `value` is provided & the `node` is an `AccessorNode`, 132 | * the `value` will be compared to that of the `AccessorNode`. 133 | * 134 | * Note that `value` here refers to the normalised value. 135 | * The property that holds the value is not always called `name`. 136 | * 137 | * @param {Node} node 138 | * @param {V} [value] 139 | * 140 | * @return {node is AccessorNode} 141 | * 142 | * @template V 143 | */ 144 | export const isSupportedAccessor = ( 145 | node: TSESTree.Node, 146 | value?: V, 147 | ): node is AccessorNode => 148 | isIdentifier(node, value) || isStringNode(node, value); 149 | 150 | /** 151 | * Gets the value of the given `AccessorNode`, 152 | * account for the different node types. 153 | * 154 | * @param {AccessorNode} accessor 155 | * 156 | * @return {S} 157 | * 158 | * @template S 159 | */ 160 | export const getAccessorValue = ( 161 | accessor: AccessorNode, 162 | ): S => 163 | accessor.type === AST_NODE_TYPES.Identifier 164 | ? accessor.name 165 | : getStringValue(accessor); 166 | 167 | export type AccessorNode = 168 | | StringNode 169 | | KnownIdentifier; 170 | -------------------------------------------------------------------------------- /src/rules/utils/followTypeAssertionChain.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; 2 | 3 | export type MaybeTypeCast = 4 | | TSTypeCastExpression 5 | | Expression; 6 | 7 | type TSTypeCastExpression< 8 | Expression extends TSESTree.Expression = TSESTree.Expression, 9 | > = AsExpressionChain | TypeAssertionChain; 10 | 11 | interface AsExpressionChain< 12 | Expression extends TSESTree.Expression = TSESTree.Expression, 13 | > extends TSESTree.TSAsExpression { 14 | expression: AsExpressionChain | Expression; 15 | } 16 | 17 | interface TypeAssertionChain< 18 | Expression extends TSESTree.Expression = TSESTree.Expression, 19 | > extends TSESTree.TSTypeAssertion { 20 | expression: TypeAssertionChain | Expression; 21 | } 22 | 23 | const isTypeCastExpression = ( 24 | node: MaybeTypeCast, 25 | ): node is TSTypeCastExpression => 26 | node.type === AST_NODE_TYPES.TSAsExpression || 27 | node.type === AST_NODE_TYPES.TSTypeAssertion; 28 | 29 | export const followTypeAssertionChain = < 30 | Expression extends TSESTree.Expression, 31 | >( 32 | expression: MaybeTypeCast, 33 | ): Expression => 34 | isTypeCastExpression(expression) 35 | ? followTypeAssertionChain(expression.expression) 36 | : expression; 37 | -------------------------------------------------------------------------------- /src/rules/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './accessors'; 2 | export * from './followTypeAssertionChain'; 3 | export * from './misc'; 4 | export * from './parseJestFnCall'; 5 | -------------------------------------------------------------------------------- /src/rules/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import { parse as parsePath } from 'path'; 2 | import { 3 | AST_NODE_TYPES, 4 | ESLintUtils, 5 | type TSESTree, 6 | } from '@typescript-eslint/utils'; 7 | import { repository, version } from '../../../package.json'; 8 | import { 9 | type AccessorNode, 10 | getAccessorValue, 11 | isSupportedAccessor, 12 | } from './accessors'; 13 | import { followTypeAssertionChain } from './followTypeAssertionChain'; 14 | import type { ParsedExpectFnCall } from './parseJestFnCall'; 15 | 16 | export const createRule = ESLintUtils.RuleCreator(name => { 17 | const ruleName = parsePath(name).name; 18 | 19 | return `${repository}/blob/v${version}/docs/rules/${ruleName}.md`; 20 | }); 21 | 22 | /** 23 | * Represents a `MemberExpression` with a "known" `property`. 24 | */ 25 | export interface KnownMemberExpression 26 | extends TSESTree.MemberExpressionComputedName { 27 | property: AccessorNode; 28 | } 29 | 30 | /** 31 | * Represents a `CallExpression` with a "known" `property` accessor. 32 | * 33 | * i.e `KnownCallExpression<'includes'>` represents `.includes()`. 34 | */ 35 | export interface KnownCallExpression 36 | extends TSESTree.CallExpression { 37 | callee: CalledKnownMemberExpression; 38 | } 39 | 40 | /** 41 | * Represents a `MemberExpression` with a "known" `property`, that is called. 42 | * 43 | * This is `KnownCallExpression` from the perspective of the `MemberExpression` node. 44 | */ 45 | interface CalledKnownMemberExpression 46 | extends KnownMemberExpression { 47 | parent: KnownCallExpression; 48 | } 49 | 50 | export enum DescribeAlias { 51 | 'describe' = 'describe', 52 | 'fdescribe' = 'fdescribe', 53 | 'xdescribe' = 'xdescribe', 54 | } 55 | 56 | export enum TestCaseName { 57 | 'fit' = 'fit', 58 | 'it' = 'it', 59 | 'test' = 'test', 60 | 'xit' = 'xit', 61 | 'xtest' = 'xtest', 62 | } 63 | 64 | export enum HookName { 65 | 'beforeAll' = 'beforeAll', 66 | 'beforeEach' = 'beforeEach', 67 | 'afterAll' = 'afterAll', 68 | 'afterEach' = 'afterEach', 69 | } 70 | 71 | export enum ModifierName { 72 | not = 'not', 73 | rejects = 'rejects', 74 | resolves = 'resolves', 75 | } 76 | 77 | export enum EqualityMatcher { 78 | toBe = 'toBe', 79 | toEqual = 'toEqual', 80 | toStrictEqual = 'toStrictEqual', 81 | } 82 | 83 | export const findTopMostCallExpression = ( 84 | node: TSESTree.CallExpression, 85 | ): TSESTree.CallExpression => { 86 | let topMostCallExpression = node; 87 | let { parent } = node; 88 | 89 | while (parent) { 90 | if (parent.type === AST_NODE_TYPES.CallExpression) { 91 | topMostCallExpression = parent; 92 | 93 | parent = parent.parent; 94 | 95 | continue; 96 | } 97 | 98 | if (parent.type !== AST_NODE_TYPES.MemberExpression) { 99 | break; 100 | } 101 | 102 | parent = parent.parent; 103 | } 104 | 105 | return topMostCallExpression; 106 | }; 107 | 108 | export const isBooleanLiteral = ( 109 | node: TSESTree.Node, 110 | ): node is TSESTree.BooleanLiteral => 111 | node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean'; 112 | 113 | export const getFirstMatcherArg = ( 114 | expectFnCall: ParsedExpectFnCall, 115 | ): TSESTree.SpreadElement | TSESTree.Expression => { 116 | const [firstArg] = expectFnCall.args; 117 | 118 | if (firstArg.type === AST_NODE_TYPES.SpreadElement) { 119 | return firstArg; 120 | } 121 | 122 | return followTypeAssertionChain(firstArg); 123 | }; 124 | 125 | export const isInstanceOfBinaryExpression = ( 126 | node: TSESTree.Node, 127 | className: string, 128 | ): node is TSESTree.BinaryExpression => 129 | node.type === AST_NODE_TYPES.BinaryExpression && 130 | node.operator === 'instanceof' && 131 | isSupportedAccessor(node.right, className); 132 | 133 | export const isParsedInstanceOfMatcherCall = ( 134 | expectFnCall: ParsedExpectFnCall, 135 | classArg?: string, 136 | ): boolean => { 137 | return ( 138 | getAccessorValue(expectFnCall.matcher) === 'toBeInstanceOf' && 139 | expectFnCall.args.length === 1 && 140 | isSupportedAccessor(expectFnCall.args[0], classArg) 141 | ); 142 | }; 143 | 144 | /** 145 | * Checks if the given `ParsedExpectMatcher` is either a call to one of the equality matchers, 146 | * with a boolean` literal as the sole argument, *or* is a call to `toBeTrue` or `toBeFalse`. 147 | */ 148 | export const isBooleanEqualityMatcher = ( 149 | expectFnCall: ParsedExpectFnCall, 150 | ): boolean => { 151 | const matcherName = getAccessorValue(expectFnCall.matcher); 152 | 153 | if (['toBeTrue', 'toBeFalse'].includes(matcherName)) { 154 | return true; 155 | } 156 | 157 | if (expectFnCall.args.length !== 1) { 158 | return false; 159 | } 160 | 161 | const arg = getFirstMatcherArg(expectFnCall); 162 | 163 | return EqualityMatcher.hasOwnProperty(matcherName) && isBooleanLiteral(arg); 164 | }; 165 | -------------------------------------------------------------------------------- /src/rules/utils/parseJestFnCall.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AST_NODE_TYPES, 3 | type TSESLint, 4 | type TSESTree, 5 | } from '@typescript-eslint/utils'; 6 | import { 7 | type AccessorNode, 8 | DescribeAlias, 9 | HookName, 10 | type KnownMemberExpression, 11 | ModifierName, 12 | TestCaseName, 13 | findTopMostCallExpression, 14 | getAccessorValue, 15 | getStringValue, 16 | isIdentifier, 17 | isStringNode, 18 | isSupportedAccessor, 19 | } from '../utils'; 20 | 21 | const joinChains = ( 22 | a: AccessorNode[] | null, 23 | b: AccessorNode[] | null, 24 | ): AccessorNode[] | null => (a && b ? [...a, ...b] : null); 25 | 26 | export function getNodeChain(node: TSESTree.Node): AccessorNode[] | null { 27 | if (isSupportedAccessor(node)) { 28 | return [node]; 29 | } 30 | 31 | switch (node.type) { 32 | case AST_NODE_TYPES.TaggedTemplateExpression: 33 | return getNodeChain(node.tag); 34 | case AST_NODE_TYPES.MemberExpression: 35 | return joinChains(getNodeChain(node.object), getNodeChain(node.property)); 36 | case AST_NODE_TYPES.CallExpression: 37 | return getNodeChain(node.callee); 38 | } 39 | 40 | return null; 41 | } 42 | 43 | export interface ResolvedJestFnWithNode extends ResolvedJestFn { 44 | node: AccessorNode; 45 | } 46 | 47 | type JestFnType = 'hook' | 'describe' | 'test' | 'expect' | 'jest' | 'unknown'; 48 | 49 | const determineJestFnType = (name: string): JestFnType => { 50 | if (name === 'expect') { 51 | return 'expect'; 52 | } 53 | 54 | if (name === 'jest') { 55 | return 'jest'; 56 | } 57 | 58 | if (DescribeAlias.hasOwnProperty(name)) { 59 | return 'describe'; 60 | } 61 | 62 | if (TestCaseName.hasOwnProperty(name)) { 63 | return 'test'; 64 | } 65 | 66 | /* istanbul ignore else */ 67 | if (HookName.hasOwnProperty(name)) { 68 | return 'hook'; 69 | } 70 | 71 | /* istanbul ignore next */ 72 | return 'unknown'; 73 | }; 74 | 75 | interface BaseParsedJestFnCall { 76 | /** 77 | * The name of the underlying Jest function that is being called. 78 | * This is the result of `(head.original ?? head.local)`. 79 | */ 80 | name: string; 81 | type: JestFnType; 82 | head: ResolvedJestFnWithNode; 83 | members: KnownMemberExpressionProperty[]; 84 | } 85 | 86 | interface ParsedGeneralJestFnCall extends BaseParsedJestFnCall { 87 | type: Exclude; 88 | } 89 | 90 | export interface ParsedExpectFnCall 91 | extends BaseParsedJestFnCall, 92 | ModifiersAndMatcher { 93 | type: 'expect'; 94 | } 95 | 96 | export type ParsedJestFnCall = ParsedGeneralJestFnCall | ParsedExpectFnCall; 97 | 98 | const ValidJestFnCallChains = [ 99 | 'afterAll', 100 | 'afterEach', 101 | 'beforeAll', 102 | 'beforeEach', 103 | 'describe', 104 | 'describe.each', 105 | 'describe.only', 106 | 'describe.only.each', 107 | 'describe.skip', 108 | 'describe.skip.each', 109 | 'fdescribe', 110 | 'fdescribe.each', 111 | 'xdescribe', 112 | 'xdescribe.each', 113 | 'it', 114 | 'it.concurrent', 115 | 'it.concurrent.failing', 116 | 'it.concurrent.each', 117 | 'it.concurrent.failing.each', 118 | 'it.concurrent.failing.only.each', 119 | 'it.concurrent.failing.skip.each', 120 | 'it.concurrent.only.each', 121 | 'it.concurrent.skip.each', 122 | 'it.each', 123 | 'it.failing', 124 | 'it.failing.each', 125 | 'it.only', 126 | 'it.only.each', 127 | 'it.only.failing', 128 | 'it.only.failing.each', 129 | 'it.skip', 130 | 'it.skip.each', 131 | 'it.skip.failing', 132 | 'it.skip.failing.each', 133 | 'it.todo', 134 | 'fit', 135 | 'fit.each', 136 | 'fit.failing', 137 | 'fit.failing.each', 138 | 'xit', 139 | 'xit.each', 140 | 'xit.failing', 141 | 'xit.failing.each', 142 | 'test', 143 | 'test.concurrent', 144 | 'test.concurrent.failing', 145 | 'test.concurrent.each', 146 | 'test.concurrent.failing.each', 147 | 'test.concurrent.failing.only.each', 148 | 'test.concurrent.failing.skip.each', 149 | 'test.concurrent.only.each', 150 | 'test.concurrent.skip.each', 151 | 'test.each', 152 | 'test.failing', 153 | 'test.failing.each', 154 | 'test.only', 155 | 'test.only.each', 156 | 'test.only.failing', 157 | 'test.only.failing.each', 158 | 'test.skip', 159 | 'test.skip.each', 160 | 'test.skip.failing', 161 | 'test.skip.failing.each', 162 | 'test.todo', 163 | 'xtest', 164 | 'xtest.each', 165 | 'xtest.failing', 166 | 'xtest.failing.each', 167 | ]; 168 | 169 | // todo: switch back to using declaration merging once https://github.com/typescript-eslint/typescript-eslint/pull/8485 170 | // is landed 171 | interface SharedConfigurationSettings { 172 | jest?: { 173 | globalAliases?: Record; 174 | globalPackage?: string; 175 | version?: number | string; 176 | }; 177 | } 178 | 179 | const resolvePossibleAliasedGlobal = ( 180 | global: string, 181 | context: TSESLint.RuleContext, 182 | ) => { 183 | const globalAliases = 184 | (context.settings as SharedConfigurationSettings).jest?.globalAliases ?? {}; 185 | 186 | const alias = Object.entries(globalAliases).find(([, aliases]) => 187 | aliases.includes(global), 188 | ); 189 | 190 | if (alias) { 191 | return alias[0]; 192 | } 193 | 194 | return null; 195 | }; 196 | 197 | const parseJestFnCallCache = new WeakMap< 198 | TSESTree.CallExpression, 199 | ParsedJestFnCall | string | null 200 | >(); 201 | 202 | export const parseJestFnCall = ( 203 | node: TSESTree.CallExpression, 204 | context: TSESLint.RuleContext, 205 | ): ParsedJestFnCall | null => { 206 | const jestFnCall = parseJestFnCallWithReason(node, context); 207 | 208 | if (typeof jestFnCall === 'string') { 209 | return null; 210 | } 211 | 212 | return jestFnCall; 213 | }; 214 | 215 | export const parseJestFnCallWithReason = ( 216 | node: TSESTree.CallExpression, 217 | context: TSESLint.RuleContext, 218 | ): ParsedJestFnCall | string | null => { 219 | let parsedJestFnCall = parseJestFnCallCache.get(node); 220 | 221 | /* istanbul ignore next */ 222 | if (parsedJestFnCall) { 223 | return parsedJestFnCall; 224 | } 225 | 226 | parsedJestFnCall = parseJestFnCallWithReasonInner(node, context); 227 | 228 | parseJestFnCallCache.set(node, parsedJestFnCall); 229 | 230 | return parsedJestFnCall; 231 | }; 232 | 233 | const parseJestFnCallWithReasonInner = ( 234 | node: TSESTree.CallExpression, 235 | context: TSESLint.RuleContext, 236 | ): ParsedJestFnCall | string | null => { 237 | const chain = getNodeChain(node); 238 | 239 | if (!chain?.length) { 240 | return null; 241 | } 242 | 243 | const [first, ...rest] = chain; 244 | 245 | const lastLink = getAccessorValue(chain[chain.length - 1]); 246 | 247 | // if we're an `each()`, ensure we're the outer CallExpression (i.e `.each()()`) 248 | if (lastLink === 'each') { 249 | if ( 250 | node.callee.type !== AST_NODE_TYPES.CallExpression && 251 | node.callee.type !== AST_NODE_TYPES.TaggedTemplateExpression 252 | ) { 253 | return null; 254 | } 255 | } 256 | 257 | if ( 258 | node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression && 259 | lastLink !== 'each' 260 | ) { 261 | return null; 262 | } 263 | 264 | const resolved = resolveToJestFn(context, first); 265 | 266 | // we're not a jest function 267 | if (!resolved) { 268 | return null; 269 | } 270 | 271 | const name = resolved.original ?? resolved.local; 272 | 273 | const links = [name, ...rest.map(link => getAccessorValue(link))]; 274 | 275 | if ( 276 | name !== 'jest' && 277 | name !== 'expect' && 278 | !ValidJestFnCallChains.includes(links.join('.')) 279 | ) { 280 | return null; 281 | } 282 | 283 | const parsedJestFnCall: Omit = { 284 | name, 285 | head: { ...resolved, node: first }, 286 | // every member node must have a member expression as their parent 287 | // in order to be part of the call chain we're parsing 288 | members: rest as KnownMemberExpressionProperty[], 289 | }; 290 | 291 | const type = determineJestFnType(name); 292 | 293 | if (type === 'expect') { 294 | const result = parseJestExpectCall(parsedJestFnCall); 295 | 296 | // if the `expect` call chain is not valid, only report on the topmost node 297 | // since all members in the chain are likely to get flagged for some reason 298 | if ( 299 | typeof result === 'string' && 300 | findTopMostCallExpression(node) !== node 301 | ) { 302 | return null; 303 | } 304 | 305 | if (result === 'matcher-not-found') { 306 | if (node.parent?.type === AST_NODE_TYPES.MemberExpression) { 307 | return 'matcher-not-called'; 308 | } 309 | } 310 | 311 | return result; 312 | } 313 | 314 | // check that every link in the chain except the last is a member expression 315 | if ( 316 | chain 317 | .slice(0, chain.length - 1) 318 | .some(nod => nod.parent?.type !== AST_NODE_TYPES.MemberExpression) 319 | ) { 320 | return null; 321 | } 322 | 323 | // ensure that we're at the "top" of the function call chain otherwise when 324 | // parsing e.g. x().y.z(), we'll incorrectly find & parse "x()" even though 325 | // the full chain is not a valid jest function call chain 326 | if ( 327 | node.parent?.type === AST_NODE_TYPES.CallExpression || 328 | node.parent?.type === AST_NODE_TYPES.MemberExpression 329 | ) { 330 | return null; 331 | } 332 | 333 | return { ...parsedJestFnCall, type }; 334 | }; 335 | 336 | type KnownMemberExpressionProperty = 337 | AccessorNode & { parent: KnownMemberExpression }; 338 | 339 | interface ModifiersAndMatcher { 340 | modifiers: KnownMemberExpressionProperty[]; 341 | matcher: KnownMemberExpressionProperty; 342 | /** The arguments that are being passed to the `matcher` */ 343 | args: TSESTree.CallExpression['arguments']; 344 | } 345 | 346 | const findModifiersAndMatcher = ( 347 | members: KnownMemberExpressionProperty[], 348 | ): ModifiersAndMatcher | string => { 349 | const modifiers: KnownMemberExpressionProperty[] = []; 350 | 351 | for (const member of members) { 352 | // check if the member is being called, which means it is the matcher 353 | // (and also the end of the entire "expect" call chain) 354 | if ( 355 | member.parent?.type === AST_NODE_TYPES.MemberExpression && 356 | member.parent.parent?.type === AST_NODE_TYPES.CallExpression 357 | ) { 358 | return { 359 | matcher: member, 360 | args: member.parent.parent.arguments, 361 | modifiers, 362 | }; 363 | } 364 | 365 | // otherwise, it should be a modifier 366 | const name = getAccessorValue(member); 367 | 368 | if (modifiers.length === 0) { 369 | // the first modifier can be any of the three modifiers 370 | if (!ModifierName.hasOwnProperty(name)) { 371 | return 'modifier-unknown'; 372 | } 373 | } else if (modifiers.length === 1) { 374 | // the second modifier can only be "not" 375 | if (name !== ModifierName.not) { 376 | return 'modifier-unknown'; 377 | } 378 | 379 | const firstModifier = getAccessorValue(modifiers[0]); 380 | 381 | // and the first modifier has to be either "resolves" or "rejects" 382 | if ( 383 | firstModifier !== ModifierName.resolves && 384 | firstModifier !== ModifierName.rejects 385 | ) { 386 | return 'modifier-unknown'; 387 | } 388 | } else { 389 | return 'modifier-unknown'; 390 | } 391 | 392 | modifiers.push(member); 393 | } 394 | 395 | // this will only really happen if there are no members 396 | return 'matcher-not-found'; 397 | }; 398 | 399 | const parseJestExpectCall = ( 400 | typelessParsedJestFnCall: Omit, 401 | ): ParsedExpectFnCall | string => { 402 | const modifiersAndMatcher = findModifiersAndMatcher( 403 | typelessParsedJestFnCall.members, 404 | ); 405 | 406 | if (typeof modifiersAndMatcher === 'string') { 407 | return modifiersAndMatcher; 408 | } 409 | 410 | return { 411 | ...typelessParsedJestFnCall, 412 | type: 'expect', 413 | ...modifiersAndMatcher, 414 | }; 415 | }; 416 | 417 | interface ImportDetails { 418 | source: string; 419 | local: string; 420 | imported: string; 421 | } 422 | 423 | const describeImportDefAsImport = ( 424 | def: TSESLint.Scope.Definitions.ImportBindingDefinition, 425 | ): ImportDetails | null => { 426 | if (def.parent.type === AST_NODE_TYPES.TSImportEqualsDeclaration) { 427 | return null; 428 | } 429 | 430 | if (def.node.type !== AST_NODE_TYPES.ImportSpecifier) { 431 | return null; 432 | } 433 | 434 | // we only care about value imports 435 | if (def.parent.importKind === 'type') { 436 | return null; 437 | } 438 | 439 | return { 440 | source: def.parent.source.value, 441 | imported: def.node.imported.name, 442 | local: def.node.local.name, 443 | }; 444 | }; 445 | 446 | /** 447 | * Attempts to find the node that represents the import source for the 448 | * given expression node, if it looks like it's an import. 449 | * 450 | * If no such node can be found (e.g. because the expression doesn't look 451 | * like an import), then `null` is returned instead. 452 | */ 453 | const findImportSourceNode = ( 454 | node: TSESTree.Expression, 455 | ): TSESTree.Node | null => { 456 | if (node.type === AST_NODE_TYPES.AwaitExpression) { 457 | if (node.argument.type === AST_NODE_TYPES.ImportExpression) { 458 | return node.argument.source; 459 | } 460 | 461 | return null; 462 | } 463 | 464 | if ( 465 | node.type === AST_NODE_TYPES.CallExpression && 466 | isIdentifier(node.callee, 'require') 467 | ) { 468 | return node.arguments[0] ?? null; 469 | } 470 | 471 | return null; 472 | }; 473 | 474 | const describeVariableDefAsImport = ( 475 | def: TSESLint.Scope.Definitions.VariableDefinition, 476 | ): ImportDetails | null => { 477 | // make sure that we've actually being assigned a value 478 | if (!def.node.init) { 479 | return null; 480 | } 481 | 482 | const sourceNode = findImportSourceNode(def.node.init); 483 | 484 | if (!sourceNode || !isStringNode(sourceNode)) { 485 | return null; 486 | } 487 | 488 | if (def.name.parent?.type !== AST_NODE_TYPES.Property) { 489 | return null; 490 | } 491 | 492 | if (!isSupportedAccessor(def.name.parent.key)) { 493 | return null; 494 | } 495 | 496 | return { 497 | source: getStringValue(sourceNode), 498 | imported: getAccessorValue(def.name.parent.key), 499 | local: def.name.name, 500 | }; 501 | }; 502 | 503 | /** 504 | * Attempts to describe a definition as an import if possible. 505 | * 506 | * If the definition is an import binding, it's described as you'd expect. 507 | * If the definition is a variable, then we try and determine if it's either 508 | * a dynamic `import()` or otherwise a call to `require()`. 509 | * 510 | * If it's neither of these, `null` is returned to indicate that the definition 511 | * is not describable as an import of any kind. 512 | */ 513 | const describePossibleImportDef = (def: TSESLint.Scope.Definition) => { 514 | if (def.type === 'Variable') { 515 | return describeVariableDefAsImport(def); 516 | } 517 | 518 | if (def.type === 'ImportBinding') { 519 | return describeImportDefAsImport(def); 520 | } 521 | 522 | return null; 523 | }; 524 | 525 | const resolveScope = ( 526 | scope: TSESLint.Scope.Scope, 527 | identifier: string, 528 | ): ImportDetails | 'local' | null => { 529 | let currentScope: TSESLint.Scope.Scope | null = scope; 530 | 531 | while (currentScope !== null) { 532 | const ref = currentScope.set.get(identifier); 533 | 534 | if (ref && ref.defs.length > 0) { 535 | const def = ref.defs[ref.defs.length - 1]; 536 | 537 | const importDetails = describePossibleImportDef(def); 538 | 539 | if (importDetails?.local === identifier) { 540 | return importDetails; 541 | } 542 | 543 | return 'local'; 544 | } 545 | 546 | currentScope = currentScope.upper; 547 | } 548 | 549 | return null; 550 | }; 551 | 552 | interface ResolvedJestFn { 553 | original: string | null; 554 | local: string; 555 | type: 'import' | 'global'; 556 | } 557 | 558 | const resolveToJestFn = ( 559 | context: TSESLint.RuleContext, 560 | accessor: AccessorNode, 561 | ): ResolvedJestFn | null => { 562 | const identifier = getAccessorValue(accessor); 563 | const maybeImport = resolveScope(getScope(context, accessor), identifier); 564 | 565 | // the identifier was found as a local variable or function declaration 566 | // meaning it's not a function from jest 567 | if (maybeImport === 'local') { 568 | return null; 569 | } 570 | 571 | if (maybeImport) { 572 | const globalPackage = 573 | (context.settings as SharedConfigurationSettings).jest?.globalPackage ?? 574 | '@jest/globals'; 575 | 576 | // the identifier is imported from our global package so return the original import name 577 | if (maybeImport.source === globalPackage) { 578 | return { 579 | original: maybeImport.imported, 580 | local: maybeImport.local, 581 | type: 'import', 582 | }; 583 | } 584 | 585 | return null; 586 | } 587 | 588 | return { 589 | original: resolvePossibleAliasedGlobal(identifier, context), 590 | local: identifier, 591 | type: 'global', 592 | }; 593 | }; 594 | 595 | /* istanbul ignore next */ 596 | const getScope = ( 597 | context: TSESLint.RuleContext, 598 | node: TSESTree.Node, 599 | ) => { 600 | const sourceCode = context.sourceCode ?? context.getSourceCode(); 601 | 602 | return sourceCode.getScope?.(node) ?? context.getScope(); 603 | }; 604 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node16/tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "stripInternal": true, 6 | 7 | /* Additional Checks */ 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "noImplicitOverride": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | 14 | "strict": true, 15 | "esModuleInterop": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "skipLibCheck": true, // until we can upgrade @typescript-eslint 19 | "forceConsistentCasingInFileNames": true 20 | }, 21 | "include": ["src/**/*"] 22 | } 23 | --------------------------------------------------------------------------------