├── .all-contributorsrc ├── .editorconfig ├── .eslint-doc-generatorrc.js ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ ├── propose_new_rule.yml │ ├── request_general_change.yml │ └── request_rule_change.yml ├── dependabot.yml ├── pull_request_template.md ├── stale.yml └── workflows │ ├── ci.yml │ ├── main-coverage.yml │ ├── release.yml │ ├── smoke-test.yml │ └── verifications.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── .releaserc.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MAINTAINERS.md ├── README.md ├── commitlint.config.js ├── docs ├── migration-guides │ ├── v4.md │ ├── v5.md │ ├── v6.md │ └── v7.md ├── rules │ ├── await-async-events.md │ ├── await-async-queries.md │ ├── await-async-utils.md │ ├── consistent-data-testid.md │ ├── no-await-sync-events.md │ ├── no-await-sync-queries.md │ ├── no-container.md │ ├── no-debugging-utils.md │ ├── no-dom-import.md │ ├── no-global-regexp-flag-in-query.md │ ├── no-manual-cleanup.md │ ├── no-node-access.md │ ├── no-promise-in-fire-event.md │ ├── no-render-in-lifecycle.md │ ├── no-test-id-queries.md │ ├── no-unnecessary-act.md │ ├── no-wait-for-multiple-assertions.md │ ├── no-wait-for-side-effects.md │ ├── no-wait-for-snapshot.md │ ├── prefer-explicit-assert.md │ ├── prefer-find-by.md │ ├── prefer-implicit-assert.md │ ├── prefer-presence-queries.md │ ├── prefer-query-by-disappearance.md │ ├── prefer-query-matchers.md │ ├── prefer-screen-queries.md │ ├── prefer-user-event.md │ └── render-result-naming-convention.md └── semantic-versioning-policy.md ├── index.d.ts ├── jest.config.js ├── lib ├── configs │ ├── angular.ts │ ├── dom.ts │ ├── index.ts │ ├── marko.ts │ ├── react.ts │ ├── svelte.ts │ └── vue.ts ├── create-testing-library-rule │ ├── detect-testing-library-utils.ts │ └── index.ts ├── index.ts ├── node-utils │ ├── index.ts │ └── is-node-of-type.ts ├── rules │ ├── await-async-events.ts │ ├── await-async-queries.ts │ ├── await-async-utils.ts │ ├── consistent-data-testid.ts │ ├── index.ts │ ├── no-await-sync-events.ts │ ├── no-await-sync-queries.ts │ ├── no-container.ts │ ├── no-debugging-utils.ts │ ├── no-dom-import.ts │ ├── no-global-regexp-flag-in-query.ts │ ├── no-manual-cleanup.ts │ ├── no-node-access.ts │ ├── no-promise-in-fire-event.ts │ ├── no-render-in-lifecycle.ts │ ├── no-test-id-queries.ts │ ├── no-unnecessary-act.ts │ ├── no-wait-for-multiple-assertions.ts │ ├── no-wait-for-side-effects.ts │ ├── no-wait-for-snapshot.ts │ ├── prefer-explicit-assert.ts │ ├── prefer-find-by.ts │ ├── prefer-implicit-assert.ts │ ├── prefer-presence-queries.ts │ ├── prefer-query-by-disappearance.ts │ ├── prefer-query-matchers.ts │ ├── prefer-screen-queries.ts │ ├── prefer-user-event.ts │ └── render-result-naming-convention.ts └── utils │ ├── compat.ts │ ├── file-import.ts │ ├── index.ts │ └── types.ts ├── lint-staged.config.js ├── package.json ├── pnpm-lock.yaml ├── tests ├── create-testing-library-rule.test.ts ├── eslint-remote-tester.config.js ├── fake-rule.ts ├── index.test.ts └── lib │ ├── rules │ ├── await-async-events.test.ts │ ├── await-async-queries.test.ts │ ├── await-async-utils.test.ts │ ├── consistent-data-testid.test.ts │ ├── no-await-sync-events.test.ts │ ├── no-await-sync-queries.test.ts │ ├── no-container.test.ts │ ├── no-debugging-utils.test.ts │ ├── no-dom-import.test.ts │ ├── no-global-regexp-flag-in-query.test.ts │ ├── no-manual-cleanup.test.ts │ ├── no-node-access.test.ts │ ├── no-promise-in-fire-event.test.ts │ ├── no-render-in-lifecycle.test.ts │ ├── no-test-id-queries.test.ts │ ├── no-unnecessary-act.test.ts │ ├── no-wait-for-multiple-assertions.test.ts │ ├── no-wait-for-side-effects.test.ts │ ├── no-wait-for-snapshot.test.ts │ ├── prefer-explicit-assert.test.ts │ ├── prefer-find-by.test.ts │ ├── prefer-implicit-assert.test.ts │ ├── prefer-presence-queries.test.ts │ ├── prefer-query-by-disappearance.test.ts │ ├── prefer-query-matchers.test.ts │ ├── prefer-screen-queries.test.ts │ ├── prefer-user-event.test.ts │ └── render-result-naming-convention.test.ts │ └── test-utils.ts ├── tools └── generate-configs │ ├── index.ts │ └── utils.ts ├── tsconfig.build.json ├── tsconfig.eslint.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = tab 13 | -------------------------------------------------------------------------------- /.eslint-doc-generatorrc.js: -------------------------------------------------------------------------------- 1 | const prettier = require('prettier'); 2 | const prettierConfig = require('./.prettierrc.js'); 3 | 4 | /** @type {import('eslint-doc-generator').GenerateOptions} */ 5 | const config = { 6 | ignoreConfig: [ 7 | 'flat/angular', 8 | 'flat/dom', 9 | 'flat/marko', 10 | 'flat/react', 11 | 'flat/svelte', 12 | 'flat/vue', 13 | ], 14 | postprocess: (content) => 15 | prettier.format(content, { ...prettierConfig, parser: 'markdown' }), 16 | }; 17 | 18 | module.exports = config; 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:import/recommended', 10 | 'plugin:jest/recommended', 11 | 'plugin:jest-formatting/recommended', 12 | 'prettier', 13 | ], 14 | rules: { 15 | // Base 16 | 'max-lines-per-function': 'off', 17 | 18 | // Import 19 | 'import/order': [ 20 | 'warn', 21 | { 22 | groups: ['builtin', 'external', 'parent', 'sibling', 'index'], 23 | 'newlines-between': 'always', 24 | alphabetize: { 25 | order: 'asc', 26 | caseInsensitive: false, 27 | }, 28 | }, 29 | ], 30 | 'import/first': 'error', 31 | 'import/no-empty-named-blocks': 'error', 32 | 'import/no-extraneous-dependencies': 'error', 33 | 'import/no-mutable-exports': 'error', 34 | 'import/no-named-default': 'error', 35 | 'import/no-relative-packages': 'warn', 36 | }, 37 | overrides: [ 38 | { 39 | // TypeScript 40 | files: ['**/*.ts?(x)'], 41 | parser: '@typescript-eslint/parser', 42 | parserOptions: { 43 | project: './tsconfig.eslint.json', 44 | tsconfigRootDir: __dirname, 45 | }, 46 | extends: [ 47 | 'plugin:@typescript-eslint/recommended', 48 | 'plugin:@typescript-eslint/recommended-type-checked', 49 | 'plugin:import/typescript', 50 | ], 51 | rules: { 52 | '@typescript-eslint/explicit-function-return-type': 'off', 53 | '@typescript-eslint/no-unused-vars': [ 54 | 'warn', 55 | { argsIgnorePattern: '^_' }, 56 | ], 57 | '@typescript-eslint/no-use-before-define': 'off', 58 | 59 | // Import 60 | // Rules enabled by `import/recommended` but are better handled by 61 | // TypeScript and @typescript-eslint. 62 | 'import/default': 'off', 63 | 'import/export': 'off', 64 | 'import/namespace': 'off', 65 | 'import/no-unresolved': 'off', 66 | }, 67 | settings: { 68 | 'import/resolver': { 69 | node: { 70 | extensions: ['.js', '.ts'], 71 | }, 72 | typescript: { 73 | alwaysTryTypes: true, 74 | }, 75 | }, 76 | }, 77 | }, 78 | ], 79 | }; 80 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: ['bug', 'triage'] 4 | type: bug 5 | body: 6 | - type: dropdown 7 | id: read_troubleshooting 8 | attributes: 9 | label: Have you read the Troubleshooting section? 10 | description: Please confirm you have read our Troubleshooting section before reporting a new bug 11 | options: 12 | - 'Yes' 13 | - 'No' 14 | validations: 15 | required: true 16 | 17 | - type: input 18 | id: plugin_version 19 | attributes: 20 | label: Plugin version 21 | description: What version of `eslint-plugin-testing-library` are you using? 22 | placeholder: v4.10.1 23 | validations: 24 | required: true 25 | 26 | - type: input 27 | id: eslint_version 28 | attributes: 29 | label: ESLint version 30 | description: What version of ESLint are you using? 31 | placeholder: v7.31.0 32 | validations: 33 | required: true 34 | 35 | - type: input 36 | id: node_js_version 37 | attributes: 38 | label: Node.js version 39 | description: What version of Node.js are you using? 40 | placeholder: 14.17.3 41 | validations: 42 | required: true 43 | 44 | - type: textarea 45 | id: bug_description 46 | attributes: 47 | label: Bug description 48 | description: Describe the bug at a high level. 49 | placeholder: I was doing ..., but I expected ... 50 | validations: 51 | required: true 52 | 53 | - type: textarea 54 | id: steps_to_reproduce 55 | attributes: 56 | label: Steps to reproduce 57 | description: Give us an ordered list of the steps to reproduce the problem. 58 | placeholder: | 59 | 1. Go to ... 60 | 2. Do .... 61 | 3. See bug 62 | validations: 63 | required: true 64 | 65 | - type: textarea 66 | id: error_output_screenshots 67 | attributes: 68 | label: Error output/screenshots 69 | description: Copy/paste any error messages or helpful screenshots into this field. 70 | placeholder: 'Tip: you can copy/paste error messages in here. You can click and drag screenshots into this field.' 71 | validations: 72 | required: false 73 | 74 | - type: textarea 75 | id: eslint_config 76 | attributes: 77 | label: ESLint configuration 78 | description: Copy/paste your ESLint configuration relevant for this plugin into this field. 79 | placeholder: 'Tip: you can find your ESLint configuration in the `.eslintrc` file.' 80 | validations: 81 | required: true 82 | 83 | - type: textarea 84 | id: rule_affected 85 | attributes: 86 | label: Rule(s) affected 87 | description: Tell us what `eslint-plugin-testing-library` rule(s) are affected by this bug. 88 | placeholder: 'Tip: check your `.eslintrc` for rules.' 89 | validations: 90 | required: true 91 | 92 | - type: textarea 93 | id: anything_else 94 | attributes: 95 | label: Anything else? 96 | description: If there's anything else we need to know, tell us about it here. 97 | placeholder: By the way, you also need to know about... 98 | validations: 99 | required: false 100 | 101 | - type: dropdown 102 | id: want_to_submit_pr_to_fix_bug 103 | attributes: 104 | label: Do you want to submit a pull request to fix this bug? 105 | options: 106 | - 'Yes' 107 | - 'Yes, but need help' 108 | - 'No' 109 | validations: 110 | required: true 111 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Troubleshooting 4 | url: https://github.com/testing-library/eslint-plugin-testing-library#troubleshooting 5 | about: Make sure you have read our Troubleshooting section before reporting a new bug 6 | 7 | - name: Ask a Question, Discuss 8 | url: https://github.com/testing-library/eslint-plugin-testing-library/discussions 9 | about: Look for `eslint-plugin-testing-library` Questions and Discussions or start a new one 10 | 11 | - name: ESLint 12 | url: https://github.com/eslint/eslint/issues 13 | about: Report problems with the ESLint program itself to the ESLint project. 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/propose_new_rule.yml: -------------------------------------------------------------------------------- 1 | name: Propose a new rule 2 | description: Propose a new rule for the eslint-plugin-testing-library. 3 | labels: ['new rule', 'triage'] 4 | type: feature 5 | body: 6 | - type: input 7 | id: name_for_new_rule 8 | attributes: 9 | label: Name for new rule 10 | description: Suggest a name for the new rule that follows the [rule naming conventions](https://github.com/testing-library/eslint-plugin-testing-library/blob/main/CONTRIBUTING.md#rule-naming-conventions). 11 | placeholder: prefer-find-by 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | id: description_of_the_new_rule 17 | attributes: 18 | label: Description of the new rule 19 | description: Please describe what the new rule should do. 20 | placeholder: The rule should ... 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | id: testing_library_feature 26 | attributes: 27 | label: Testing Library feature 28 | description: What Testing Library feature does this rule relate to? 29 | placeholder: Enforce promises from async queries to be handled 30 | validations: 31 | required: true 32 | 33 | - type: textarea 34 | id: testing_library_frameworks 35 | attributes: 36 | label: Testing Library framework(s) 37 | description: What Testing Library framework(s) does this rule relate to? 38 | placeholder: Angular, React and Vue 39 | validations: 40 | required: true 41 | 42 | - type: dropdown 43 | id: category_of_rule 44 | attributes: 45 | label: What category of rule is this? 46 | options: 47 | - 'Warns about a potential error' 48 | - 'Suggests an alternate way of doing something' 49 | - 'Other (specify in textbox below)' 50 | validations: 51 | required: true 52 | 53 | - type: textarea 54 | id: textbox_category_of_rule_when_selected_other 55 | attributes: 56 | label: 'Optional: other category of rule' 57 | description: 'If you selected _other_ in the dropdown menu above, explain more here.' 58 | placeholder: Explain the category of rule in more detail. 59 | validations: 60 | required: false 61 | 62 | - type: textarea 63 | id: code_examples 64 | attributes: 65 | label: Code examples 66 | description: Provide two to three code examples that this rule will warn about. 67 | placeholder: Code example 1, code example 2. 68 | validations: 69 | required: true 70 | 71 | - type: textarea 72 | id: anything_else 73 | attributes: 74 | label: Anything else? 75 | description: If there's anything else we need to know, tell us about it here. 76 | placeholder: By the way, you also need to know about... 77 | validations: 78 | required: false 79 | 80 | - type: dropdown 81 | id: want_to_submit_pr_to_make_new_rule 82 | attributes: 83 | label: Do you want to submit a pull request to make the new rule? 84 | options: 85 | - 'Yes' 86 | - 'Yes, but need help' 87 | - 'No' 88 | validations: 89 | required: true 90 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/request_general_change.yml: -------------------------------------------------------------------------------- 1 | name: Request a general change 2 | description: Request a general change for the eslint-plugin-testing-library. 3 | labels: ['enhancement', 'triage'] 4 | type: task 5 | body: 6 | - type: input 7 | id: plugin_version 8 | attributes: 9 | label: Plugin version 10 | description: What version of `eslint-plugin-testing-library` are you using? 11 | placeholder: v4.10.1 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | id: problem_that_needs_solving 17 | attributes: 18 | label: What problem do you want to solve? 19 | description: Tell us about your problem. 20 | placeholder: New Node version is not supported officially 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | id: proposed_solution 26 | attributes: 27 | label: Your take on the correct solution? 28 | description: Tell us your idea to solve the problem. 29 | placeholder: Update Node versions in `package.json` 30 | validations: 31 | required: true 32 | 33 | - type: textarea 34 | id: anything_else 35 | attributes: 36 | label: Anything else? 37 | description: If there's anything else we need to know, tell us about it here. 38 | placeholder: By the way, you also need to know about... 39 | validations: 40 | required: false 41 | 42 | - type: dropdown 43 | id: want_to_submit_pr_to_implement_change 44 | attributes: 45 | label: Do you want to submit a pull request to implement this change? 46 | options: 47 | - 'Yes' 48 | - 'Yes, but need help' 49 | - 'No' 50 | validations: 51 | required: true 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/request_rule_change.yml: -------------------------------------------------------------------------------- 1 | name: Request a rule change 2 | description: Request a rule change for the eslint-plugin-testing-library. 3 | labels: ['enhancement', 'triage'] 4 | type: feature 5 | body: 6 | - type: input 7 | id: what_rule_do_you_want_to_change 8 | attributes: 9 | label: What rule do you want to change? 10 | description: Enter the name of the rule you want to change. 11 | placeholder: prefer-find-by 12 | validations: 13 | required: true 14 | 15 | - type: dropdown 16 | id: rule_change_more_fewer_warnings 17 | attributes: 18 | label: Does this change cause the rule to produce more or fewer warnings? 19 | options: 20 | - 'More warnings' 21 | - 'Fewer warnings' 22 | validations: 23 | required: true 24 | 25 | - type: textarea 26 | id: change_implementation 27 | attributes: 28 | label: How will the change be implemented? 29 | description: Describe how the rule change will be implemented. 30 | validations: 31 | required: true 32 | 33 | - type: textarea 34 | id: example_code 35 | attributes: 36 | label: Example code 37 | description: Please provide some example code that this change will affect. 38 | placeholder: Example of code that will be affected by proposed change. 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: current_rule_affects_code 44 | attributes: 45 | label: How does the current rule affect the code? 46 | description: Explain how the current rule affects the example code. 47 | placeholder: The current rule affects the example code like this... 48 | validations: 49 | required: true 50 | 51 | - type: textarea 52 | id: new_rule_affects_code 53 | attributes: 54 | label: How will the new rule affect the code? 55 | description: Explain how the rule change will affect the example code. 56 | placeholder: The new rule will affect the example code like this... 57 | validations: 58 | required: true 59 | 60 | - type: textarea 61 | id: anything_else 62 | attributes: 63 | label: Anything else? 64 | description: If there's anything else we need to know, tell us about it here. 65 | placeholder: By the way, you also need to know about... 66 | validations: 67 | required: false 68 | 69 | - type: dropdown 70 | id: want_to_submit_pr_to_change_rule 71 | attributes: 72 | label: Do you want to submit a pull request to change the rule? 73 | options: 74 | - 'Yes' 75 | - 'Yes, but need help' 76 | - 'No' 77 | validations: 78 | required: true 79 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | 8 | - package-ecosystem: npm 9 | directory: / 10 | schedule: 11 | interval: monthly 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Checks 2 | 3 | - [ ] I have read the [contributing guidelines](https://github.com/testing-library/eslint-plugin-testing-library/blob/main/CONTRIBUTING.md). 4 | 5 | ## Changes 6 | 7 | 8 | 9 | - 10 | 11 | ## Context 12 | 13 | 17 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Only issues or pull requests with all of these labels are checked if stale 6 | onlyLabels: 7 | - 'awaiting response' 8 | - 'new rule' 9 | - enhancement 10 | - invalid 11 | # Issues with these labels will never be considered stale 12 | exemptLabels: 13 | - pinned 14 | - security 15 | - triage 16 | # Label to use when marking an issue as stale 17 | staleLabel: wontfix 18 | # Comment to post when marking an issue as stale. Set to `false` to disable 19 | markComment: > 20 | This issue has been automatically marked as stale because it has not had 21 | recent activity. It will be closed if no further activity occurs. Thank you 22 | for your contributions. 23 | # Comment to post when closing a stale issue. Set to `false` to disable 24 | closeComment: false 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | merge_group: 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | verifications: 13 | name: Verifications 14 | uses: ./.github/workflows/verifications.yml 15 | 16 | required-checks: 17 | name: Require CI status checks 18 | runs-on: ubuntu-latest 19 | if: ${{ !cancelled() && github.event.action != 'closed' }} 20 | needs: [verifications] 21 | steps: 22 | - run: ${{ !contains(needs.*.result, 'failure') }} 23 | - run: ${{ !contains(needs.*.result, 'cancelled') }} 24 | -------------------------------------------------------------------------------- /.github/workflows/main-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage (main) 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | 7 | permissions: 8 | contents: read 9 | statuses: write 10 | 11 | jobs: 12 | coverage: 13 | name: Code Coverage 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 3 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@v4 22 | 23 | - name: Set up Node 24 | uses: actions/setup-node@v4 25 | with: 26 | cache: 'pnpm' 27 | node-version-file: '.nvmrc' 28 | 29 | - name: Install dependencies 30 | run: pnpm install 31 | 32 | - name: Run tests with coverage 33 | run: pnpm run test:ci 34 | 35 | - name: Upload coverage report 36 | uses: codecov/codecov-action@v5 37 | env: 38 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | # semantic-release valid branches 7 | - '+([0-9])?(.{+([0-9]),x}).x' 8 | - 'main' 9 | - 'next' 10 | - 'next-major' 11 | - 'beta' 12 | - 'alpha' 13 | 14 | concurrency: 15 | group: release 16 | cancel-in-progress: false 17 | 18 | permissions: 19 | contents: write # to be able to publish a GitHub release 20 | id-token: write # to enable use of OIDC for npm provenance 21 | issues: write # to be able to comment on released issues 22 | pull-requests: write # to be able to comment on released pull requests 23 | 24 | jobs: 25 | publish: 26 | name: Publish package 27 | runs-on: ubuntu-latest 28 | # Avoid publishing in forks 29 | if: github.repository == 'testing-library/eslint-plugin-testing-library' 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | 34 | - name: Install pnpm 35 | uses: pnpm/action-setup@v4 36 | 37 | - name: Set up Node 38 | uses: actions/setup-node@v4 39 | with: 40 | cache: 'pnpm' 41 | node-version-file: '.nvmrc' 42 | 43 | - name: Install dependencies 44 | run: pnpm install 45 | 46 | - name: Build package 47 | run: pnpm run build 48 | 49 | - name: Release new version 50 | run: pnpm exec semantic-release 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | NPM_CONFIG_PROVENANCE: true 54 | NPM_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }} 55 | -------------------------------------------------------------------------------- /.github/workflows/smoke-test.yml: -------------------------------------------------------------------------------- 1 | name: Smoke test 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * SUN' 6 | workflow_dispatch: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v4 19 | 20 | - name: Use Node 21 | uses: actions/setup-node@v4 22 | with: 23 | cache: 'pnpm' 24 | node-version-file: '.nvmrc' 25 | 26 | - run: | 27 | pnpm install 28 | pnpm run build 29 | 30 | - run: pnpm link 31 | working-directory: ./dist 32 | 33 | - run: pnpm link eslint-plugin-testing-library 34 | 35 | - uses: AriPerkkio/eslint-remote-tester-run-action@v4 36 | with: 37 | issue-title: 'Results of weekly scheduled smoke test' 38 | eslint-remote-tester-config: tests/eslint-remote-tester.config.js 39 | -------------------------------------------------------------------------------- /.github/workflows/verifications.yml: -------------------------------------------------------------------------------- 1 | name: Verifications 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | code-validation: 8 | name: 'Code Validation: ${{ matrix.validation-script }}' 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | validation-script: 14 | ['lint', 'type-check', 'format:check', 'generate-all:check'] 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v4 21 | 22 | - name: Set up Node 23 | uses: actions/setup-node@v4 24 | with: 25 | cache: 'pnpm' 26 | node-version-file: '.nvmrc' 27 | 28 | - name: Install dependencies 29 | run: pnpm install 30 | 31 | - name: Run script 32 | run: pnpm run ${{ matrix.validation-script }} 33 | 34 | tests: 35 | name: Tests (Node v${{ matrix.node }} - ESLint v${{ matrix.eslint }}) 36 | runs-on: ubuntu-latest 37 | timeout-minutes: 3 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | node: [18.18.0, 18, 20.9.0, 20, 21.1.0, 21, 22, 23] 42 | eslint: [8.57.0, 8, 9] 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v4 46 | 47 | - name: Install pnpm 48 | uses: pnpm/action-setup@v4 49 | 50 | - name: Set up Node 51 | uses: actions/setup-node@v4 52 | with: 53 | cache: 'pnpm' 54 | node-version: ${{ matrix.node }} 55 | 56 | - name: Install dependencies 57 | run: pnpm install 58 | 59 | - name: Install ESLint v${{ matrix.eslint }} 60 | run: pnpm add eslint@${{ matrix.eslint }} 61 | 62 | - name: Run tests 63 | run: pnpm run test:ci 64 | 65 | - name: Upload coverage reports to Codecov 66 | uses: codecov/codecov-action@v5 67 | env: 68 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Output 2 | dist 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # Mac files 61 | .DS_Store 62 | 63 | # Yarn 64 | yarn-error.log 65 | .pnp/ 66 | .pnp.js 67 | # Yarn Integrity file 68 | .yarn-integrity 69 | 70 | # Ignore locks other than pnpm 71 | package-lock.json 72 | yarn.lock 73 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | # Skip commit-msg hook on CI 2 | [ -n "$CI" ] && exit 0 3 | 4 | commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | # Skip pre-commit hook on CI 2 | [ -n "$CI" ] && exit 0 3 | 4 | lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | enable-pre-post-scripts=true 3 | public-hoist-pattern[]=@commitlint* 4 | public-hoist-pattern[]=commitlint -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | .all-contributorsrc 5 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | singleQuote: true, 4 | useTabs: true, 5 | }; 6 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "+([0-9])?(.{+([0-9]),x}).x", 4 | "main", 5 | "next", 6 | "next-major", 7 | { 8 | "name": "beta", 9 | "prerelease": true 10 | }, 11 | { 12 | "name": "alpha", 13 | "prerelease": true 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mario Beltrán Alarcón 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 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | This document outlines some processes that the maintainers should stick to. 2 | 3 | ## Node.js version 4 | 5 | We will try to stick to the same range of [Node.js versions supported by ESLint](https://github.com/eslint/eslint#installation-and-usage) as much as possible. 6 | 7 | For local development and CI, we will use the Maintenance LTS version (specified in `.nvmrc` file). 8 | 9 | ## Issues Process 10 | 11 | There are 3 types of issues that can be created: 12 | 13 | - `"bug"` 14 | - `"new rule"` 15 | - `"enhancement"` 16 | 17 | ### Triage 18 | 19 | The triage process is basically making sure that the issue is correctly reported (and ask for more info if not), and categorize it correctly if it belongs to a different type than initially assigned. 20 | 21 | - When a new issue is created, they'll include a `"triage"` label 22 | - If the issue is correctly reported, please remove the `"triage"` label, so we know is valid and ready to be tackled 23 | - If the issue is **not** correctly reported, please ask for more details and add the `"awaiting response"` label, so we know more info has been requested to the author 24 | - If the issue belong to an incorrect category, please update the labels to put it in the right category 25 | - If the issue is duplicated, please close it including a comment with a link to the duplicating issue 26 | 27 | ## Pull Requests Process 28 | 29 | ### Main PR workflow 30 | 31 | _TODO: pending to describe the main PR process_ 32 | 33 | ### Contributors 34 | 35 | When the PR gets merged, please check if the author of the PR or the closed issue (if any) should be added or updated in the [Contributors section](https://github.com/testing-library/eslint-plugin-testing-library#contributors-). 36 | 37 | If so, you can ask the [`@all-contributors` bot to add a contributor](https://allcontributors.org/docs/en/bot/usage) in a comment of the merged PR (this works for both adding and updating). Remember to check the [Contribution Types table](https://allcontributors.org/docs/en/emoji-key) to decide which sort of contribution should be assigned. 38 | 39 | ## Stale bot 40 | 41 | This repo uses [probot-stale](https://github.com/probot/stale) to close abandoned issues and PRs after a period of inactivity. 42 | 43 | They'll be considered inactive if they match all the following conditions: 44 | 45 | - they have been 60 days inactive 46 | - they have at least one of the following labels: 47 | - `"awaiting response"`: we are waiting for more details but the author didn't react 48 | - `"new rule"`: there is a proposal for a new rule that no one could handle 49 | - `"enhancement"`: there is a proposal for an enhancement that no one could handle 50 | - `"invalid"`: something is wrong with the issue/PR and the author didn't take care of it 51 | 52 | When flagged as a stale issue or PR, they'll be definitely closed after 7 more days of inactivity. Issues and PRs with the following labels are excluded: `"pinned"`, `"security"`, and "`triage"`. Use the first one if you need to exclude an issue or PR from being closed for whatever reason even if the inactive criteria is matched. 53 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/migration-guides/v5.md: -------------------------------------------------------------------------------- 1 | # Guide: migrating to v5 2 | 3 | Assuming you are already in v4, migrating to v5 will be easy. If you are not in v4 yet, we recommend you to follow [the proper guide to migrate to it](docs/migration-guides/v4.md). 4 | 5 | ## Overview 6 | 7 | - Support for ESLint v8 8 | - Drop support for Node v10. Required node version is now `^12.22.0 || ^14.17.0 || >=16.0.0`. Node v10 was EOL'd in April 2021, and ESLint v8 dropped support for it too. 9 | - Update dependencies 10 | - `no-debug` is now called `no-debugging-utils` 11 | - `no-render-in-setup` is now enabled by default in the Angular, React & Vue configs 12 | - `no-unnecessary-act`'s `isStrict` option is now `true` by default 13 | - `no-unnecessary-act` is now enabled by default in the React config 14 | - `no-wait-for-multiple-assertions` is now enabled by default in all configs 15 | - `no-wait-for-side-effects` is now enabled by default in all configs 16 | - `no-wait-for-snapshot` is now enabled by default in all configs 17 | - `prefer-presence-queries` is now enabled by default in all configs 18 | - `prefer-query-by-disappearance` is now enabled by default in all configs 19 | 20 | ## Steps to upgrade 21 | 22 | - `eslint-plugin-testing-library` supports both ESLint v7 and v8, so you are fine with either version 23 | - Making sure you are using a compatible Node version (`^12.22.0 || ^14.17.0 || >=16.0.0`), and update it if it's not the case 24 | - Renaming `testing-library/no-debug` to `testing-library/no-debugging-utils` if you were referencing it manually somewhere 25 | - Being aware of new rules enabled in Shared Configs which can lead to new reported errors 26 | -------------------------------------------------------------------------------- /docs/migration-guides/v6.md: -------------------------------------------------------------------------------- 1 | # Guide: migrating to v6 2 | 3 | If you are not on v5 yet, we recommend first following the [v5 migration guide](docs/migration-guides/v5.md). 4 | 5 | ## Overview 6 | 7 | - `prefer-wait-for` was removed 8 | - `no-wait-for-empty-callback` was removed 9 | - `await-fire-event` is now called `await-async-events` with support for an `eventModule` option with `userEvent` and/or `fireEvent` 10 | - `await-async-events` is now enabled by default for `fireEvent` in Vue and Marko shared configs 11 | - `await-async-events` is now enabled by default for `userEvent` in all shared configs 12 | - `await-async-query` is now called `await-async-queries` 13 | - `no-await-sync-query` is now called `no-await-sync-queries` 14 | - `no-render-in-setup` is now called `no-render-in-lifecycle` 15 | - `no-await-sync-events` is now enabled by default in React, Angular, and DOM shared configs 16 | - `no-manual-cleanup` is now enabled by default in React and Vue shared configs 17 | - `no-global-regexp-flag-in-query` is now enabled by default in all shared configs 18 | - `no-node-access` is now enabled by default in DOM shared config 19 | - `no-debugging-utils` now reports all debugging utility methods by default 20 | - `no-debugging-utils` now defaults to `warn` instead of `error` in all shared configs 21 | 22 | ## Steps to upgrade 23 | 24 | - Removing `testing-library/prefer-wait-for` if you were referencing it manually somewhere 25 | - Removing `testing-library/no-wait-for-empty-callback` if you were referencing it manually somewhere 26 | - Renaming `testing-library/await-fire-event` to `testing-library/await-async-events` if you were referencing it manually somewhere 27 | - Renaming `testing-library/await-async-query` to `testing-library/await-async-queries` if you were referencing it manually somewhere 28 | - Renaming `testing-library/no-await-sync-query` to `testing-library/no-await-sync-queries` if you were referencing it manually somewhere 29 | - Renaming `testing-library/no-render-in-setup` to `testing-library/no-render-in-lifecycle` if you were referencing it manually somewhere 30 | - Being aware of new rules enabled or changed above in shared configs which can lead to newly reported errors 31 | -------------------------------------------------------------------------------- /docs/migration-guides/v7.md: -------------------------------------------------------------------------------- 1 | # Guide: migrating to v7 2 | 3 | If you are not on v6 yet, we recommend first following the [v6 migration guide](docs/migration-guides/v6.md). 4 | 5 | ## Overview 6 | 7 | - **(Breaking)** Supported versions of Node.js have been updated to `^18.18.0`, `^20.9.0`, or `>=21.1.0`, matching ESLint. 8 | - **(Breaking)** Supported versions of ESLint have been updated to `^8.57.0`, or `^9.0.0`. 9 | - Full support for ESLint v9 (v8 still compatible) and typescript-eslint v8 10 | 11 | ## Steps to upgrade 12 | 13 | 1. Make sure you are using a supported version of Node.js, and upgrade if not. 14 | 2. Make sure you are using a supported version of ESLint, and upgrade if not. 15 | -------------------------------------------------------------------------------- /docs/rules/await-async-events.md: -------------------------------------------------------------------------------- 1 | # Enforce promises from async event methods are handled (`testing-library/await-async-events`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | Ensure that promises returned by `userEvent` (v14+) async methods or `fireEvent` (only Vue and Marko) async methods are handled properly. 10 | 11 | ## Rule Details 12 | 13 | This rule aims to prevent users from forgetting to handle promise returned from async event 14 | methods. 15 | 16 | > ⚠️ `fireEvent` methods are async only on following Testing Library packages: 17 | > 18 | > - `@testing-library/vue` (supported by this plugin) 19 | > - `@testing-library/svelte` (not supported yet by this plugin) 20 | > - `@marko/testing-library` (supported by this plugin) 21 | 22 | Examples of **incorrect** code for this rule: 23 | 24 | ```js 25 | fireEvent.click(getByText('Click me')); 26 | 27 | fireEvent.focus(getByLabelText('username')); 28 | fireEvent.blur(getByLabelText('username')); 29 | 30 | // wrap a fireEvent method within a function... 31 | function triggerEvent() { 32 | return fireEvent.click(button); 33 | } 34 | triggerEvent(); // ...but not handling promise from it is incorrect too 35 | ``` 36 | 37 | ```js 38 | userEvent.click(getByText('Click me')); 39 | userEvent.tripleClick(getByText('Click me')); 40 | userEvent.keyboard('foo'); 41 | 42 | // wrap a userEvent method within a function... 43 | function triggerEvent() { 44 | return userEvent.click(button); 45 | } 46 | triggerEvent(); // ...but not handling promise from it is incorrect too 47 | ``` 48 | 49 | Examples of **correct** code for this rule: 50 | 51 | ```js 52 | // `await` operator is correct 53 | await fireEvent.focus(getByLabelText('username')); 54 | await fireEvent.blur(getByLabelText('username')); 55 | 56 | // `then` method is correct 57 | fireEvent.click(getByText('Click me')).then(() => { 58 | // ... 59 | }); 60 | 61 | // return the promise within a function is correct too! 62 | const clickMeArrowFn = () => fireEvent.click(getByText('Click me')); 63 | 64 | // wrap a fireEvent method within a function... 65 | function triggerEvent() { 66 | return fireEvent.click(button); 67 | } 68 | await triggerEvent(); // ...and handling promise from it is correct also 69 | 70 | // using `Promise.all` or `Promise.allSettled` with an array of promises is valid 71 | await Promise.all([ 72 | fireEvent.focus(getByLabelText('username')), 73 | fireEvent.blur(getByLabelText('username')), 74 | ]); 75 | ``` 76 | 77 | ```js 78 | // `await` operator is correct 79 | await userEvent.click(getByText('Click me')); 80 | await userEvent.tripleClick(getByText('Click me')); 81 | 82 | // `then` method is correct 83 | userEvent.keyboard('foo').then(() => { 84 | // ... 85 | }); 86 | 87 | // return the promise within a function is correct too! 88 | const clickMeArrowFn = () => userEvent.click(getByText('Click me')); 89 | 90 | // wrap a userEvent method within a function... 91 | function triggerEvent() { 92 | return userEvent.click(button); 93 | } 94 | await triggerEvent(); // ...and handling promise from it is correct also 95 | 96 | // using `Promise.all` or `Promise.allSettled` with an array of promises is valid 97 | await Promise.all([ 98 | userEvent.click(getByText('Click me')); 99 | userEvent.tripleClick(getByText('Click me')); 100 | ]); 101 | ``` 102 | 103 | ## Options 104 | 105 | - `eventModule`: `string` or `string[]`. Which event module should be linted for async event methods. Defaults to `userEvent` which should be used after v14. `fireEvent` should only be used with frameworks that have async fire event methods. 106 | 107 | ## Example 108 | 109 | ```json 110 | { 111 | "testing-library/await-async-events": [ 112 | 2, 113 | { 114 | "eventModule": "userEvent" 115 | } 116 | ] 117 | } 118 | ``` 119 | 120 | ```json 121 | { 122 | "testing-library/await-async-events": [ 123 | 2, 124 | { 125 | "eventModule": "fireEvent" 126 | } 127 | ] 128 | } 129 | ``` 130 | 131 | ```json 132 | { 133 | "testing-library/await-async-events": [ 134 | 2, 135 | { 136 | "eventModule": ["fireEvent", "userEvent"] 137 | } 138 | ] 139 | } 140 | ``` 141 | 142 | ## When Not To Use It 143 | 144 | - `userEvent` is below v14, before all event methods are async 145 | - `fireEvent` methods are sync for most Testing Library packages. If you are not using Testing Library package with async events, you shouldn't use this rule. 146 | 147 | ## Further Reading 148 | 149 | - [Vue Testing Library fireEvent](https://testing-library.com/docs/vue-testing-library/api#fireevent) 150 | -------------------------------------------------------------------------------- /docs/rules/await-async-queries.md: -------------------------------------------------------------------------------- 1 | # Enforce promises from async queries to be handled (`testing-library/await-async-queries`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | Ensure that promises returned by async queries are handled properly. 8 | 9 | ## Rule Details 10 | 11 | Some queries variants that Testing Library provides are 12 | asynchronous as they return a promise which resolves when elements are 13 | found. Those queries variants are: 14 | 15 | - `findBy*` 16 | - `findAllBy*` 17 | 18 | This rule aims to prevent users from forgetting to handle the returned 19 | promise from those async queries, which could lead to 20 | problems in the tests. The promise will be considered as handled when: 21 | 22 | - using the `await` operator 23 | - wrapped within `Promise.all` or `Promise.allSettled` methods 24 | - chaining the `then` method 25 | - chaining `resolves` or `rejects` from jest 26 | - chaining `toResolve()` or `toReject()` from [jest-extended](https://github.com/jest-community/jest-extended#promise) 27 | - it's returned from a function (in this case, that particular function will be analyzed by this rule too) 28 | 29 | Examples of **incorrect** code for this rule: 30 | 31 | ```js 32 | // async query without handling promise 33 | const rows = findAllByRole('row'); 34 | 35 | findByIcon('search'); 36 | 37 | screen.findAllByPlaceholderText('name'); 38 | ``` 39 | 40 | ```js 41 | // promise from async query returned within wrapper function without being handled 42 | const findMyButton = () => findByText('my button'); 43 | 44 | const someButton = findMyButton(); // promise unhandled here 45 | ``` 46 | 47 | Examples of **correct** code for this rule: 48 | 49 | ```js 50 | // `await` operator is correct 51 | const rows = await findAllByRole('row'); 52 | 53 | await screen.findAllByPlaceholderText('name'); 54 | 55 | const promise = findByIcon('search'); 56 | const element = await promise; 57 | ``` 58 | 59 | ```js 60 | // `then` method is correct 61 | findByText('submit').then(() => {}); 62 | 63 | const promise = findByRole('button'); 64 | promise.then(() => {}); 65 | ``` 66 | 67 | ```js 68 | // return the promise within a function is correct too! 69 | const findMyButton = () => findByText('my button'); 70 | ``` 71 | 72 | ```js 73 | // promise from async query returned within wrapper function being handled 74 | const findMyButton = () => findByText('my button'); 75 | 76 | const someButton = await findMyButton(); 77 | ``` 78 | 79 | ```js 80 | // several promises handled with `Promise.all` is correct 81 | await Promise.all([findByText('my button'), findByText('something else')]); 82 | ``` 83 | 84 | ```js 85 | // several promises handled `Promise.allSettled` is correct 86 | await Promise.allSettled([ 87 | findByText('my button'), 88 | findByText('something else'), 89 | ]); 90 | ``` 91 | 92 | ```js 93 | // using a resolves/rejects matcher is also correct 94 | expect(findByTestId('alert')).resolves.toBe('Success'); 95 | expect(findByTestId('alert')).rejects.toBe('Error'); 96 | ``` 97 | 98 | ```js 99 | // using a toResolve/toReject matcher is also correct 100 | expect(findByTestId('alert')).toResolve(); 101 | expect(findByTestId('alert')).toReject(); 102 | ``` 103 | 104 | ```js 105 | // sync queries don't need to handle any promise 106 | const element = getByRole('role'); 107 | ``` 108 | 109 | ## Further Reading 110 | 111 | - [Async queries variants](https://testing-library.com/docs/dom-testing-library/api-queries#findby) 112 | - [Testing Library queries cheatsheet](https://testing-library.com/docs/dom-testing-library/cheatsheet#queries) 113 | -------------------------------------------------------------------------------- /docs/rules/await-async-utils.md: -------------------------------------------------------------------------------- 1 | # Enforce promises from async utils to be awaited properly (`testing-library/await-async-utils`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | Ensure that promises returned by async utils are handled properly. 8 | 9 | ## Rule Details 10 | 11 | Testing library provides several utilities for dealing with asynchronous code. These are useful to wait for an element until certain criteria or situation happens. The available async utils are: 12 | 13 | - `waitFor` 14 | - `waitForElementToBeRemoved` 15 | 16 | This rule aims to prevent users from forgetting to handle the returned 17 | promise from async utils, which could lead to 18 | problems in the tests. The promise will be considered as handled when: 19 | 20 | - using the `await` operator 21 | - wrapped within `Promise.all` or `Promise.allSettled` methods 22 | - chaining the `then` method 23 | - chaining `resolves` or `rejects` from jest 24 | - chaining `toResolve()` or `toReject()` from [jest-extended](https://github.com/jest-community/jest-extended#promise) 25 | - it's returned from a function (in this case, that particular function will be analyzed by this rule too) 26 | 27 | Examples of **incorrect** code for this rule: 28 | 29 | ```js 30 | test('something incorrectly', async () => { 31 | // ... 32 | waitFor(() => {}); 33 | 34 | const [usernameElement, passwordElement] = waitFor( 35 | () => [ 36 | getByLabelText(container, 'username'), 37 | getByLabelText(container, 'password'), 38 | ], 39 | { container } 40 | ); 41 | 42 | waitFor(() => {}, { timeout: 100 }); 43 | 44 | waitForElementToBeRemoved(() => document.querySelector('div.getOuttaHere')); 45 | 46 | // wrap an async util within a function... 47 | const makeCustomWait = () => { 48 | return waitForElementToBeRemoved(() => 49 | document.querySelector('div.getOuttaHere') 50 | ); 51 | }; 52 | makeCustomWait(); // ...but not handling promise from it is incorrect 53 | }); 54 | ``` 55 | 56 | Examples of **correct** code for this rule: 57 | 58 | ```js 59 | test('something correctly', async () => { 60 | // ... 61 | // `await` operator is correct 62 | await waitFor(() => getByLabelText('email')); 63 | 64 | const [usernameElement, passwordElement] = await waitFor( 65 | () => [ 66 | getByLabelText(container, 'username'), 67 | getByLabelText(container, 'password'), 68 | ], 69 | { container } 70 | ); 71 | 72 | // `then` chained method is correct 73 | waitFor(() => {}, { timeout: 100 }) 74 | .then(() => console.log('DOM changed!')) 75 | .catch((err) => console.log(`Error you need to deal with: ${err}`)); 76 | 77 | // wrap an async util within a function... 78 | const makeCustomWait = () => { 79 | return waitForElementToBeRemoved(() => 80 | document.querySelector('div.getOuttaHere') 81 | ); 82 | }; 83 | await makeCustomWait(); // ...and handling promise from it is correct 84 | 85 | // using Promise.all combining the methods 86 | await Promise.all([ 87 | waitFor(() => getByLabelText('email')), 88 | waitForElementToBeRemoved(() => document.querySelector('div.getOuttaHere')), 89 | ]); 90 | 91 | // Using jest resolves or rejects 92 | expect(waitFor(() => getByLabelText('email'))).resolves.toBeUndefined(); 93 | 94 | // Using jest-extended a toResolve/toReject matcher is also correct 95 | expect(waitFor(() => getByLabelText('email'))).toResolve(); 96 | }); 97 | ``` 98 | 99 | ## Further Reading 100 | 101 | - [Async Utilities](https://testing-library.com/docs/dom-testing-library/api-async) 102 | -------------------------------------------------------------------------------- /docs/rules/consistent-data-testid.md: -------------------------------------------------------------------------------- 1 | # Ensures consistent usage of `data-testid` (`testing-library/consistent-data-testid`) 2 | 3 | 4 | 5 | Ensure `data-testid` values match a provided regex. This rule is un-opinionated, and requires configuration. 6 | 7 | > ⚠️ This rule is only available in the following Testing Library packages: 8 | > 9 | > - `@testing-library/react` (supported by this plugin) 10 | 11 | ## Rule Details 12 | 13 | > Assuming the rule has been configured with the following regex: `^TestId(\_\_[A-Z]*)?$` 14 | 15 | Examples of **incorrect** code for this rule: 16 | 17 | ```js 18 | const foo = (props) =>
...
; 19 | const foo = (props) =>
...
; 20 | const foo = (props) =>
...
; 21 | ``` 22 | 23 | Examples of **correct** code for this rule: 24 | 25 | ```js 26 | const foo = (props) =>
...
; 27 | const bar = (props) =>
...
; 28 | const baz = (props) =>
...
; 29 | ``` 30 | 31 | ## Options 32 | 33 | | Option | Required | Default | Details | Example | 34 | | ----------------- | -------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | 35 | | `testIdPattern` | Yes | None | A regex used to validate the format of the `data-testid` value. `{fileName}` can optionally be used as a placeholder and will be substituted with the name of the file OR the name of the files parent directory in the case when the file name is `index.js` OR empty string in the case of dynamically changing routes (that contain square brackets) with `Gatsby.js` or `Next.js` | `^{fileName}(\_\_([A-Z]+[a-z]_?)+)_\$` | 36 | | `testIdAttribute` | No | `data-testid` | A string (or array of strings) used to specify the attribute used for querying by ID. This is only required if data-testid has been explicitly overridden in the [RTL configuration](https://testing-library.com/docs/dom-testing-library/api-queries#overriding-data-testid) | `data-my-test-attribute`, `["data-testid", "testId"]` | 37 | | `customMessage` | No | `undefined` | A string used to display a custom message whenever warnings/errors are reported. | `A custom message` | 38 | 39 | ## Example 40 | 41 | ```js 42 | module.exports = { 43 | rules: { 44 | 'testing-library/consistent-data-testid': [ 45 | 'error', 46 | { testIdPattern: '^TestId(__[A-Z]*)?$' }, 47 | ], 48 | }, 49 | }; 50 | ``` 51 | 52 | ```js 53 | module.exports = { 54 | rules: { 55 | 'testing-library/consistent-data-testid': [ 56 | 'error', 57 | { testIdAttribute: ['data-testid', 'testId'] }, 58 | ], 59 | }, 60 | }; 61 | ``` 62 | 63 | ```js 64 | module.exports = { 65 | rules: { 66 | 'testing-library/consistent-data-testid': [ 67 | 'error', 68 | { customMessage: 'A custom message' }, 69 | ], 70 | }, 71 | }; 72 | ``` 73 | 74 | ## Notes 75 | 76 | - If you are using Gatsby.js's [client-only routes](https://www.gatsbyjs.com/docs/reference/routing/file-system-route-api/#syntax-client-only-routes) or Next.js's [dynamic routes](https://nextjs.org/docs/routing/dynamic-routes) and therefore have square brackets (`[]`) in the filename (e.g. `../path/to/[component].js`), the `{fileName}` placeholder will be replaced with an empty string. This is because a linter cannot know what the dynamic content will be at run time. 77 | -------------------------------------------------------------------------------- /docs/rules/no-await-sync-events.md: -------------------------------------------------------------------------------- 1 | # Disallow unnecessary `await` for sync events (`testing-library/no-await-sync-events`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `react`. 4 | 5 | 6 | 7 | Ensure that sync events are not awaited unnecessarily. 8 | 9 | ## Rule Details 10 | 11 | Methods for simulating events in Testing Library ecosystem -`fireEvent` and `userEvent` prior to v14 - 12 | do NOT return any Promise, with an exception of 13 | `userEvent.type` and `userEvent.keyboard`, which delays the promise resolve only if [`delay` 14 | option](https://github.com/testing-library/user-event#typeelement-text-options) is specified. 15 | 16 | Some examples of simulating events not returning any Promise are: 17 | 18 | - `fireEvent.click` 19 | - `fireEvent.select` 20 | - `userEvent.tab` (prior to `user-event` v14) 21 | - `userEvent.hover` (prior to `user-event` v14) 22 | 23 | This rule aims to prevent users from waiting for those function calls. 24 | 25 | > ⚠️ `fire-event` methods are async only on following Testing Library packages: 26 | > 27 | > - `@testing-library/vue` (supported by this plugin) 28 | > - `@testing-library/svelte` (not supported yet by this plugin) 29 | > - `@marko/testing-library` (supported by this plugin) 30 | 31 | Examples of **incorrect** code for this rule: 32 | 33 | ```js 34 | const foo = async () => { 35 | // ... 36 | await fireEvent.click(button); 37 | // ... 38 | }; 39 | 40 | const bar = async () => { 41 | // ... 42 | // userEvent prior to v14 43 | await userEvent.tab(); 44 | // ... 45 | }; 46 | 47 | const baz = async () => { 48 | // ... 49 | // userEvent prior to v14 50 | await userEvent.type(textInput, 'abc'); 51 | await userEvent.keyboard('abc'); 52 | // ... 53 | }; 54 | ``` 55 | 56 | Examples of **correct** code for this rule: 57 | 58 | ```js 59 | const foo = () => { 60 | // ... 61 | fireEvent.click(button); 62 | // ... 63 | }; 64 | 65 | const bar = () => { 66 | // ... 67 | userEvent.tab(); 68 | // ... 69 | }; 70 | 71 | const baz = async () => { 72 | // await userEvent.type only with delay option 73 | await userEvent.type(textInput, 'abc', { delay: 1000 }); 74 | userEvent.type(textInput, '123'); 75 | 76 | // same for userEvent.keyboard 77 | await userEvent.keyboard(textInput, 'abc', { delay: 1000 }); 78 | userEvent.keyboard('123'); 79 | // ... 80 | }; 81 | 82 | const qux = async () => { 83 | // userEvent v14 84 | await userEvent.tab(); 85 | await userEvent.click(button); 86 | await userEvent.type(textInput, 'abc'); 87 | await userEvent.keyboard('abc'); 88 | // ... 89 | }; 90 | ``` 91 | 92 | ## Options 93 | 94 | This rule provides the following options: 95 | 96 | - `eventModules`: array of strings. Defines which event module should be linted for sync event methods. The possibilities are: `"fire-event"` and `"user-event"`. Defaults to `["fire-event"]`. 97 | 98 | ### Example: 99 | 100 | ```js 101 | module.exports = { 102 | rules: { 103 | 'testing-library/no-await-sync-events': [ 104 | 'error', 105 | { eventModules: ['fire-event', 'user-event'] }, 106 | ], 107 | }, 108 | }; 109 | ``` 110 | 111 | ## When Not To Use It 112 | 113 | - `"fire-event"` option: should be disabled only for those Testing Library packages where fire-event methods are async. 114 | - `"user-event"` option: should be disabled only if using v14 or greater. 115 | 116 | ## Notes 117 | 118 | There is another rule `await-async-events`, which is for awaiting async events for `user-event` v14 or `fire-event` only in Testing Library packages with async methods. Please do not confuse with this rule. 119 | -------------------------------------------------------------------------------- /docs/rules/no-await-sync-queries.md: -------------------------------------------------------------------------------- 1 | # Disallow unnecessary `await` for sync queries (`testing-library/no-await-sync-queries`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | Ensure that sync queries are not awaited unnecessarily. 8 | 9 | ## Rule Details 10 | 11 | Usual queries variants that Testing Library provides are synchronous and 12 | don't need to wait for any element. Those queries are: 13 | 14 | - `getBy*` 15 | - `getByAll*` 16 | - `queryBy*` 17 | - `queryAllBy*` 18 | 19 | This rule aims to prevent users from waiting for synchronous queries. 20 | 21 | Examples of **incorrect** code for this rule: 22 | 23 | ```js 24 | const foo = async () => { 25 | // ... 26 | const rows = await queryAllByRole('row'); 27 | // ... 28 | }; 29 | 30 | const bar = () => { 31 | // ... 32 | getByText('submit').then(() => { 33 | // ... 34 | }); 35 | }; 36 | 37 | const baz = () => { 38 | // ... 39 | const button = await screen.getByText('submit'); 40 | }; 41 | ``` 42 | 43 | Examples of **correct** code for this rule: 44 | 45 | ```js 46 | const foo = () => { 47 | // ... 48 | const rows = queryAllByRole('row'); 49 | // ... 50 | }; 51 | 52 | const bar = () => { 53 | // ... 54 | const button = getByText('submit'); 55 | // ... 56 | }; 57 | 58 | const baz = () => { 59 | // ... 60 | const button = screen.getByText('submit'); 61 | }; 62 | ``` 63 | 64 | ## Further Reading 65 | 66 | - [Sync queries variants](https://testing-library.com/docs/dom-testing-library/api-queries#variants) 67 | - [Testing Library queries cheatsheet](https://testing-library.com/docs/dom-testing-library/cheatsheet#queries) 68 | -------------------------------------------------------------------------------- /docs/rules/no-container.md: -------------------------------------------------------------------------------- 1 | # Disallow the use of `container` methods (`testing-library/no-container`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | By using `container` methods like `.querySelector` you may lose a lot of the confidence that the user can really interact with your UI. Also, the test becomes harder to read, and it will break more frequently. 8 | 9 | This applies to Testing Library frameworks built on top of **DOM Testing Library** 10 | 11 | ## Rule Details 12 | 13 | This rule aims to disallow the use of `container` methods in your tests. 14 | 15 | Examples of **incorrect** code for this rule: 16 | 17 | ```js 18 | const { container } = render(); 19 | const button = container.querySelector('.btn-primary'); 20 | ``` 21 | 22 | ```js 23 | const { container: alias } = render(); 24 | const button = alias.querySelector('.btn-primary'); 25 | ``` 26 | 27 | ```js 28 | const view = render(); 29 | const button = view.container.getElementsByClassName('.btn-primary'); 30 | ``` 31 | 32 | Examples of **correct** code for this rule: 33 | 34 | ```js 35 | render(); 36 | screen.getByRole('button', { name: /click me/i }); 37 | ``` 38 | 39 | ## Further Reading 40 | 41 | - [about the `container` element](https://testing-library.com/docs/react-testing-library/api#container-1) 42 | - [querying with `screen`](https://testing-library.com/docs/dom-testing-library/api-queries#screen) 43 | -------------------------------------------------------------------------------- /docs/rules/no-debugging-utils.md: -------------------------------------------------------------------------------- 1 | # Disallow the use of debugging utilities like `debug` (`testing-library/no-debugging-utils`) 2 | 3 | ⚠️ This rule _warns_ in the following configs: `angular`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | Just like `console.log` statements pollutes the browser's output, debug statements also pollutes the tests if one of your teammates forgot to remove it. `debug` statements should be used when you actually want to debug your tests but should not be pushed to the codebase. 8 | 9 | ## Rule Details 10 | 11 | This rule supports disallowing the following debugging utilities: 12 | 13 | - `debug` 14 | - `logTestingPlaygroundURL` 15 | - `prettyDOM` 16 | - `logRoles` 17 | - `logDOM` 18 | - `prettyFormat` 19 | 20 | By default, all are disallowed. 21 | 22 | Examples of **incorrect** code for this rule: 23 | 24 | ```js 25 | const { debug } = render(); 26 | debug(); 27 | ``` 28 | 29 | ```js 30 | const utils = render(); 31 | utils.debug(); 32 | ``` 33 | 34 | ```js 35 | import { screen } from '@testing-library/dom'; 36 | screen.debug(); 37 | ``` 38 | 39 | ```js 40 | const { screen } = require('@testing-library/react'); 41 | screen.debug(); 42 | ``` 43 | 44 | ## Options 45 | 46 | You can control which debugging utils are checked for with the `utilsToCheckFor` option: 47 | 48 | ```js 49 | module.exports = { 50 | rules: { 51 | 'testing-library/no-debugging-utils': [ 52 | 'error', 53 | { 54 | utilsToCheckFor: { 55 | debug: false, 56 | logRoles: true, 57 | logDOM: true, 58 | }, 59 | }, 60 | ], 61 | }, 62 | }; 63 | ``` 64 | 65 | ## Further Reading 66 | 67 | - [debug API in React Testing Library](https://testing-library.com/docs/react-testing-library/api#debug) 68 | - [`screen.debug` in Dom Testing Library](https://testing-library.com/docs/dom-testing-library/api-queries#screendebug) 69 | -------------------------------------------------------------------------------- /docs/rules/no-dom-import.md: -------------------------------------------------------------------------------- 1 | # Disallow importing from DOM Testing Library (`testing-library/no-dom-import`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | Ensure that there are no direct imports from `@testing-library/dom` or 10 | `dom-testing-library` when using some testing library framework 11 | wrapper. 12 | 13 | ## Rule Details 14 | 15 | Testing Library framework wrappers as React Testing Library already 16 | re-exports everything from DOM Testing Library, so you always have to 17 | import Testing Library utils from corresponding framework wrapper 18 | module to: 19 | 20 | - use proper extended version of some of those methods containing 21 | additional functionality related to specific framework (e.g. 22 | `fireEvent` util) 23 | - avoid importing from extraneous dependencies (similar to 24 | `eslint-plugin-import`) 25 | 26 | This rule aims to prevent users from import anything directly from 27 | `@testing-library/dom`, which is useful for 28 | new starters or when IDEs autoimport from wrong module. 29 | 30 | Examples of **incorrect** code for this rule: 31 | 32 | ```js 33 | import { fireEvent } from 'dom-testing-library'; 34 | ``` 35 | 36 | ```js 37 | import { fireEvent } from '@testing-library/dom'; 38 | ``` 39 | 40 | ```js 41 | import { render } from '@testing-library/react'; // Okay, no error 42 | import { screen } from '@testing-library/dom'; // Error, unnecessary import from @testing-library/dom 43 | ``` 44 | 45 | ```js 46 | const { fireEvent } = require('dom-testing-library'); 47 | ``` 48 | 49 | ```js 50 | const { fireEvent } = require('@testing-library/dom'); 51 | ``` 52 | 53 | Examples of **correct** code for this rule: 54 | 55 | ```js 56 | import { fireEvent } from 'react-testing-library'; 57 | ``` 58 | 59 | ```js 60 | import { fireEvent } from '@testing-library/react'; 61 | ``` 62 | 63 | ```js 64 | const { fireEvent } = require('react-testing-library'); 65 | ``` 66 | 67 | ```js 68 | const { fireEvent } = require('@testing-library/react'); 69 | ``` 70 | 71 | ## Options 72 | 73 | This rule has an option in case you want to tell the user which framework to use. 74 | 75 | ### Example 76 | 77 | ```js 78 | module.exports = { 79 | rules: { 80 | 'testing-library/no-dom-import': ['error', 'react'], 81 | }, 82 | }; 83 | ``` 84 | 85 | With the configuration above, if the user imports from `@testing-library/dom` or `dom-testing-library` instead of the used framework, ESLint will tell the user to import from `@testing-library/react` or `react-testing-library`. 86 | 87 | ## Further Reading 88 | 89 | - [Angular Testing Library API](https://testing-library.com/docs/angular-testing-library/api) 90 | - [React Testing Library API](https://testing-library.com/docs/react-testing-library/api) 91 | - [Vue Testing Library API](https://testing-library.com/docs/vue-testing-library/api) 92 | - [Marko Testing Library API](https://testing-library.com/docs/marko-testing-library/api) 93 | -------------------------------------------------------------------------------- /docs/rules/no-global-regexp-flag-in-query.md: -------------------------------------------------------------------------------- 1 | # Disallow the use of the global RegExp flag (/g) in queries (`testing-library/no-global-regexp-flag-in-query`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | Ensure that there are no global RegExp flags used when using queries. 10 | 11 | ## Rule Details 12 | 13 | A RegExp instance that's using the global flag `/g` holds state and this might cause false-positives while querying for elements. 14 | 15 | Examples of **incorrect** code for this rule: 16 | 17 | ```js 18 | screen.getByText(/hello/gi); 19 | ``` 20 | 21 | ```js 22 | await screen.findByRole('button', { otherProp: true, name: /hello/g }); 23 | ``` 24 | 25 | Examples of **correct** code for this rule: 26 | 27 | ```js 28 | screen.getByText(/hello/i); 29 | ``` 30 | 31 | ```js 32 | await screen.findByRole('button', { otherProp: true, name: /hello/ }); 33 | ``` 34 | 35 | ## Further Reading 36 | 37 | - [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/lastIndex) 38 | -------------------------------------------------------------------------------- /docs/rules/no-manual-cleanup.md: -------------------------------------------------------------------------------- 1 | # Disallow the use of `cleanup` (`testing-library/no-manual-cleanup`) 2 | 3 | 💼 This rule is enabled in the following configs: `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | `cleanup` is performed automatically if the testing framework you're using supports the `afterEach` global (like mocha, Jest, and Jasmine). In this case, it's unnecessary to do manual cleanups after each test unless you skip the auto-cleanup with environment variables such as `RTL_SKIP_AUTO_CLEANUP` for React. 8 | 9 | ## Rule Details 10 | 11 | This rule disallows the import/use of `cleanup` in your test files. It fires if you import `cleanup` from one of these libraries: 12 | 13 | - [Marko Testing Library](https://testing-library.com/docs/marko-testing-library/api#cleanup) 14 | - [Preact Testing Library](https://testing-library.com/docs/preact-testing-library/api#cleanup) 15 | - [React Testing Library](https://testing-library.com/docs/react-testing-library/api#cleanup) 16 | - [Svelte Testing Library](https://testing-library.com/docs/svelte-testing-library/api#cleanup) 17 | - [Vue Testing Library](https://testing-library.com/docs/vue-testing-library/api#cleanup) 18 | 19 | Examples of **incorrect** code for this rule: 20 | 21 | ```js 22 | import { cleanup } from '@testing-library/react'; 23 | 24 | const { cleanup } = require('@testing-library/react'); 25 | 26 | import utils from '@testing-library/react'; 27 | afterEach(() => utils.cleanup()); 28 | 29 | const utils = require('@testing-library/react'); 30 | afterEach(utils.cleanup); 31 | ``` 32 | 33 | Examples of **correct** code for this rule: 34 | 35 | ```js 36 | import { cleanup } from 'any-other-library'; 37 | 38 | const utils = require('any-other-library'); 39 | utils.cleanup(); 40 | ``` 41 | 42 | ## Further Reading 43 | 44 | - [cleanup API in React Testing Library](https://testing-library.com/docs/react-testing-library/api#cleanup) 45 | - [Skipping Auto Cleanup](https://testing-library.com/docs/react-testing-library/setup#skipping-auto-cleanup) 46 | -------------------------------------------------------------------------------- /docs/rules/no-node-access.md: -------------------------------------------------------------------------------- 1 | # Disallow direct Node access (`testing-library/no-node-access`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | The Testing Library already provides methods for querying DOM elements. 8 | 9 | ## Rule Details 10 | 11 | This rule aims to disallow DOM traversal using native HTML methods and properties, such as `closest`, `lastChild` and all that returns another Node element from an HTML tree. 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```js 16 | import { screen } from '@testing-library/react'; 17 | 18 | screen.getByText('Submit').closest('button'); // chaining with Testing Library methods 19 | ``` 20 | 21 | ```js 22 | import { screen } from '@testing-library/react'; 23 | 24 | const buttons = screen.getAllByRole('button'); 25 | expect(buttons[1].lastChild).toBeInTheDocument(); 26 | ``` 27 | 28 | ```js 29 | import { screen } from '@testing-library/react'; 30 | 31 | const buttonText = screen.getByText('Submit'); 32 | const button = buttonText.closest('button'); 33 | ``` 34 | 35 | Examples of **correct** code for this rule: 36 | 37 | ```js 38 | import { screen } from '@testing-library/react'; 39 | 40 | const button = screen.getByRole('button'); 41 | expect(button).toHaveTextContent('submit'); 42 | ``` 43 | 44 | ```js 45 | import { render, within } from '@testing-library/react'; 46 | 47 | const { getByLabelText } = render(); 48 | const signinModal = getByLabelText('Sign In'); 49 | within(signinModal).getByPlaceholderText('Username'); 50 | ``` 51 | 52 | ```js 53 | import { screen } from '@testing-library/react'; 54 | 55 | function ComponentA(props) { 56 | // props.children is not reported 57 | return
{props.children}
; 58 | } 59 | 60 | render(); 61 | ``` 62 | 63 | ```js 64 | // If is not importing a testing-library package 65 | 66 | document.getElementById('submit-btn').closest('button'); 67 | ``` 68 | 69 | ## Options 70 | 71 | This rule has one option: 72 | 73 | - `allowContainerFirstChild`: **disabled by default**. When we have container 74 | with rendered content then the easiest way to access content itself is [by using 75 | `firstChild` property](https://testing-library.com/docs/react-testing-library/api/#container-1). Use this option in cases when this is hardly avoidable. 76 | 77 | ```js 78 | "testing-library/no-node-access": ["error", {"allowContainerFirstChild": true}] 79 | ``` 80 | 81 | Correct: 82 | 83 | ```jsx 84 | const { container } = render(); 85 | expect(container.firstChild).toMatchSnapshot(); 86 | ``` 87 | 88 | ## Further Reading 89 | 90 | ### Properties / methods that return another Node 91 | 92 | - [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document) 93 | - [`Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element) 94 | - [`Node`](https://developer.mozilla.org/en-US/docs/Web/API/Node) 95 | -------------------------------------------------------------------------------- /docs/rules/no-promise-in-fire-event.md: -------------------------------------------------------------------------------- 1 | # Disallow the use of promises passed to a `fireEvent` method (`testing-library/no-promise-in-fire-event`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | Methods from `fireEvent` expect to receive a DOM element. Passing a promise will end up in an error, so it must be prevented. 8 | 9 | Examples of **incorrect** code for this rule: 10 | 11 | ```js 12 | import { screen, fireEvent } from '@testing-library/react'; 13 | 14 | // usage of unhandled findBy queries 15 | fireEvent.click(screen.findByRole('button')); 16 | 17 | // usage of unhandled promises 18 | fireEvent.click(new Promise(jest.fn())); 19 | 20 | // usage of references to unhandled promises 21 | const promise = new Promise(); 22 | fireEvent.click(promise); 23 | 24 | const anotherPromise = screen.findByRole('button'); 25 | fireEvent.click(anotherPromise); 26 | ``` 27 | 28 | Examples of **correct** code for this rule: 29 | 30 | ```js 31 | import { screen, fireEvent } from '@testing-library/react'; 32 | 33 | // usage of getBy queries 34 | fireEvent.click(screen.getByRole('button')); 35 | 36 | // usage of awaited findBy queries 37 | fireEvent.click(await screen.findByRole('button')); 38 | 39 | // usage of references to handled promises 40 | const promise = new Promise(); 41 | const element = await promise; 42 | fireEvent.click(element); 43 | 44 | const anotherPromise = screen.findByRole('button'); 45 | const button = await anotherPromise; 46 | fireEvent.click(button); 47 | ``` 48 | 49 | ## Further Reading 50 | 51 | - [A Github Issue explaining the problem](https://github.com/testing-library/dom-testing-library/issues/609) 52 | -------------------------------------------------------------------------------- /docs/rules/no-render-in-lifecycle.md: -------------------------------------------------------------------------------- 1 | # Disallow the use of `render` in testing frameworks setup functions (`testing-library/no-render-in-lifecycle`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule disallows the usage of `render` (or a custom render function) in testing framework setup functions (`beforeEach` and `beforeAll`) in favor of moving `render` closer to test assertions. 10 | 11 | This rule reduces the amount of variable mutation, in particular avoiding nesting `beforeEach` functions. According to Kent C. Dodds, that results in vastly simpler test maintenance. 12 | 13 | For more background on the origin and rationale for this best practice, read Kent C. Dodds's [Avoid Nesting when you're Testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing). 14 | 15 | Examples of **incorrect** code for this rule: 16 | 17 | ```js 18 | beforeEach(() => { 19 | render(); 20 | }); 21 | 22 | it('Should have foo', () => { 23 | expect(screen.getByText('foo')).toBeInTheDocument(); 24 | }); 25 | 26 | it('Should have bar', () => { 27 | expect(screen.getByText('bar')).toBeInTheDocument(); 28 | }); 29 | ``` 30 | 31 | ```js 32 | const setup = () => render(); 33 | 34 | beforeEach(() => { 35 | setup(); 36 | }); 37 | 38 | it('Should have foo', () => { 39 | expect(screen.getByText('foo')).toBeInTheDocument(); 40 | }); 41 | 42 | it('Should have bar', () => { 43 | expect(screen.getByText('bar')).toBeInTheDocument(); 44 | }); 45 | ``` 46 | 47 | ```js 48 | beforeAll(() => { 49 | render(); 50 | }); 51 | 52 | it('Should have foo', () => { 53 | expect(screen.getByText('foo')).toBeInTheDocument(); 54 | }); 55 | 56 | it('Should have bar', () => { 57 | expect(screen.getByText('bar')).toBeInTheDocument(); 58 | }); 59 | ``` 60 | 61 | Examples of **correct** code for this rule: 62 | 63 | ```js 64 | it('Should have foo and bar', () => { 65 | render(); 66 | expect(screen.getByText('foo')).toBeInTheDocument(); 67 | expect(screen.getByText('bar')).toBeInTheDocument(); 68 | }); 69 | ``` 70 | 71 | ```js 72 | const setup = () => render(); 73 | 74 | beforeEach(() => { 75 | // other stuff... 76 | }); 77 | 78 | it('Should have foo and bar', () => { 79 | setup(); 80 | expect(screen.getByText('foo')).toBeInTheDocument(); 81 | expect(screen.getByText('bar')).toBeInTheDocument(); 82 | }); 83 | ``` 84 | 85 | ## Options 86 | 87 | If you would like to allow the use of `render` (or a custom render function) in _either_ `beforeAll` or `beforeEach`, this can be configured using the option `allowTestingFrameworkSetupHook`. This may be useful if you have configured your tests to [skip auto cleanup](https://testing-library.com/docs/react-testing-library/setup#skipping-auto-cleanup). `allowTestingFrameworkSetupHook` is an enum that accepts either `"beforeAll"` or `"beforeEach"`. 88 | 89 | ``` 90 | "testing-library/no-render-in-lifecycle": ["error", {"allowTestingFrameworkSetupHook": "beforeAll"}], 91 | ``` 92 | -------------------------------------------------------------------------------- /docs/rules/no-test-id-queries.md: -------------------------------------------------------------------------------- 1 | # Ensure no `data-testid` queries are used (`testing-library/no-test-id-queries`) 2 | 3 | 4 | 5 | ## Rule Details 6 | 7 | This rule aims to reduce the usage of `*ByTestId` queries in your tests. 8 | 9 | When using `*ByTestId` queries, you are coupling your tests to the implementation details of your components, and not to how they behave and being used. 10 | 11 | Prefer using queries that are more related to the user experience, like `getByRole`, `getByLabelText`, etc. 12 | 13 | Example of **incorrect** code for this rule: 14 | 15 | ```js 16 | const button = queryByTestId('my-button'); 17 | const input = screen.queryByTestId('my-input'); 18 | ``` 19 | 20 | Examples of **correct** code for this rule: 21 | 22 | ```js 23 | const button = screen.getByRole('button'); 24 | const input = screen.getByRole('textbox'); 25 | ``` 26 | 27 | ## Further Reading 28 | 29 | - [about `getByTestId`](https://testing-library.com/docs/queries/bytestid) 30 | - [about `getByRole`](https://testing-library.com/docs/queries/byrole) 31 | - [about `getByLabelText`](https://testing-library.com/docs/queries/bylabeltext) 32 | - [Common mistakes with React Testing Library - Not querying by text](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#not-querying-by-text) 33 | -------------------------------------------------------------------------------- /docs/rules/no-unnecessary-act.md: -------------------------------------------------------------------------------- 1 | # Disallow wrapping Testing Library utils or empty callbacks in `act` (`testing-library/no-unnecessary-act`) 2 | 3 | 💼 This rule is enabled in the following configs: `marko`, `react`. 4 | 5 | 6 | 7 | > ⚠️ The `act` method is only available on the following Testing Library packages: 8 | > 9 | > - `@testing-library/react` (supported by this plugin) 10 | > - `@testing-library/preact` (not supported yet by this plugin) 11 | > - `@testing-library/svelte` (not supported yet by this plugin) 12 | > - `@marko/testing-library` (supported by this plugin) 13 | 14 | ## Rule Details 15 | 16 | This rule aims to avoid the usage of `act` to wrap Testing Library utils just to silence "not wrapped in act(...)" warnings. 17 | 18 | All Testing Library utils are already wrapped in `act`. Most of the time, if you're seeing an `act` warning, it's not just something to be silenced, but it's actually telling you that something unexpected is happening in your test. 19 | 20 | Additionally, wrapping empty callbacks in `act` is also an incorrect way of silencing "not wrapped in act(...)" warnings. 21 | 22 | Code violations reported by this rule will pinpoint those unnecessary `act`, helping to understand when `act` actually is necessary. 23 | 24 | Example of **incorrect** code for this rule: 25 | 26 | ```js 27 | // ❌ wrapping things related to Testing Library in `act` is incorrect 28 | import { 29 | act, 30 | render, 31 | screen, 32 | waitFor, 33 | fireEvent, 34 | } from '@testing-library/react'; 35 | // ^ act imported from 'react-dom/test-utils' will be reported too 36 | import userEvent from '@testing-library/user-event'; 37 | 38 | // ... 39 | 40 | act(() => { 41 | render(); 42 | }); 43 | 44 | await act(async () => waitFor(() => {})); 45 | 46 | act(() => screen.getByRole('button')); 47 | 48 | act(() => { 49 | fireEvent.click(element); 50 | }); 51 | 52 | act(() => { 53 | userEvent.click(element); 54 | }); 55 | ``` 56 | 57 | ```js 58 | // ❌ wrapping empty callbacks in `act` is incorrect 59 | import { act } from '@testing-library/react'; 60 | // ^ act imported from 'react-dom/test-utils' will be reported too 61 | import userEvent from '@testing-library/user-event'; 62 | 63 | // ... 64 | 65 | act(() => {}); 66 | 67 | await act(async () => {}); 68 | ``` 69 | 70 | Examples of **correct** code for this rule: 71 | 72 | ```js 73 | // ✅ wrapping things not related to Testing Library in `act` is correct 74 | import { act } from '@testing-library/react'; 75 | import { stuffThatDoesNotUseRTL } from 'somwhere-else'; 76 | 77 | // ... 78 | 79 | act(() => { 80 | stuffThatDoesNotUseRTL(); 81 | }); 82 | ``` 83 | 84 | ```js 85 | // ✅ wrapping both things related and not related to Testing Library in `act` is correct 86 | import { act, screen } from '@testing-library/react'; 87 | import { stuffThatDoesNotUseRTL } from 'somwhere-else'; 88 | 89 | await act(async () => { 90 | await screen.findByRole('button'); 91 | stuffThatDoesNotUseRTL(); 92 | }); 93 | ``` 94 | 95 | ## Options 96 | 97 | This rule has one option: 98 | 99 | - `isStrict`: **enabled by default**. Wrapping both things related and not related to Testing Library in `act` is reported 100 | 101 | ```js 102 | "testing-library/no-unnecessary-act": ["error", {"isStrict": true}] 103 | ``` 104 | 105 | Incorrect: 106 | 107 | ```jsx 108 | // ❌ wrapping both things related and not related to Testing Library in `act` is NOT correct 109 | 110 | import { act, screen } from '@testing-library/react'; 111 | import { stuffThatDoesNotUseRTL } from 'somwhere-else'; 112 | 113 | await act(async () => { 114 | await screen.findByRole('button'); 115 | stuffThatDoesNotUseRTL(); 116 | }); 117 | ``` 118 | 119 | ## Further Reading 120 | 121 | - [Inspiration for this rule](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#wrapping-things-in-act-unnecessarily) 122 | - [Fix the "not wrapped in act(...)" warning](https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning) 123 | - [About React Testing Library `act`](https://testing-library.com/docs/react-testing-library/api/#act) 124 | -------------------------------------------------------------------------------- /docs/rules/no-wait-for-multiple-assertions.md: -------------------------------------------------------------------------------- 1 | # Disallow the use of multiple `expect` calls inside `waitFor` (`testing-library/no-wait-for-multiple-assertions`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule aims to ensure the correct usage of `expect` inside `waitFor`, in the way that they're intended to be used. 10 | When using multiple assertions inside `waitFor`, if one fails, you have to wait for a timeout before seeing it failing. 11 | Putting one assertion, you can both wait for the UI to settle to the state you want to assert on, 12 | and also fail faster if one of the assertions do end up failing 13 | 14 | Example of **incorrect** code for this rule: 15 | 16 | ```js 17 | const foo = async () => { 18 | await waitFor(() => { 19 | expect(a).toEqual('a'); 20 | expect(b).toEqual('b'); 21 | }); 22 | 23 | // or 24 | await waitFor(function () { 25 | expect(a).toEqual('a'); 26 | expect(b).toEqual('b'); 27 | }); 28 | }; 29 | ``` 30 | 31 | Examples of **correct** code for this rule: 32 | 33 | ```js 34 | const foo = async () => { 35 | await waitFor(() => expect(a).toEqual('a')); 36 | expect(b).toEqual('b'); 37 | 38 | // or 39 | await waitFor(function () { 40 | expect(a).toEqual('a'); 41 | }); 42 | expect(b).toEqual('b'); 43 | 44 | // it only detects expect 45 | // so this case doesn't generate warnings 46 | await waitFor(() => { 47 | fireEvent.keyDown(input, { key: 'ArrowDown' }); 48 | expect(b).toEqual('b'); 49 | }); 50 | }; 51 | ``` 52 | 53 | ## Further Reading 54 | 55 | - [about `waitFor`](https://testing-library.com/docs/dom-testing-library/api-async#waitfor) 56 | - [inspiration for this rule](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#having-multiple-assertions-in-a-single-waitfor-callback) 57 | -------------------------------------------------------------------------------- /docs/rules/no-wait-for-side-effects.md: -------------------------------------------------------------------------------- 1 | # Disallow the use of side effects in `waitFor` (`testing-library/no-wait-for-side-effects`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule aims to avoid the usage of side effects actions (`fireEvent`, `userEvent` or `render`) inside `waitFor`. 10 | Since `waitFor` is intended for things that have a non-deterministic amount of time between the action you performed and the assertion passing, 11 | the callback can be called (or checked for errors) a non-deterministic number of times and frequency. 12 | This will make your side-effect run multiple times. 13 | 14 | Example of **incorrect** code for this rule: 15 | 16 | ```js 17 | await waitFor(() => { 18 | fireEvent.keyDown(input, { key: 'ArrowDown' }); 19 | expect(b).toEqual('b'); 20 | }); 21 | 22 | // or 23 | await waitFor(function() { 24 | fireEvent.keyDown(input, { key: 'ArrowDown' }); 25 | expect(b).toEqual('b'); 26 | }); 27 | 28 | // or 29 | await waitFor(() => { 30 | userEvent.click(button); 31 | expect(b).toEqual('b'); 32 | }); 33 | 34 | // or 35 | await waitFor(function() { 36 | userEvent.click(button); 37 | expect(b).toEqual('b'); 38 | }); 39 | 40 | // or 41 | await waitFor(() => { 42 | render() 43 | expect(b).toEqual('b'); 44 | }); 45 | 46 | // or 47 | await waitFor(function() { 48 | render() 49 | expect(b).toEqual('b'); 50 | }); 51 | }; 52 | ``` 53 | 54 | Examples of **correct** code for this rule: 55 | 56 | ```js 57 | fireEvent.keyDown(input, { key: 'ArrowDown' }); 58 | await waitFor(() => { 59 | expect(b).toEqual('b'); 60 | }); 61 | 62 | // or 63 | fireEvent.keyDown(input, { key: 'ArrowDown' }); 64 | await waitFor(function() { 65 | expect(b).toEqual('b'); 66 | }); 67 | 68 | // or 69 | userEvent.click(button); 70 | await waitFor(() => { 71 | expect(b).toEqual('b'); 72 | }); 73 | 74 | // or 75 | userEvent.click(button); 76 | await waitFor(function() { 77 | expect(b).toEqual('b'); 78 | }); 79 | 80 | // or 81 | userEvent.click(button); 82 | waitFor(function() { 83 | expect(b).toEqual('b'); 84 | }).then(() => { 85 | // Outside of waitFor, e.g. inside a .then() side effects are allowed 86 | fireEvent.click(button); 87 | }); 88 | 89 | // or 90 | render() 91 | await waitFor(() => { 92 | expect(b).toEqual('b'); 93 | }); 94 | 95 | // or 96 | render() 97 | await waitFor(function() { 98 | expect(b).toEqual('b'); 99 | }); 100 | }; 101 | ``` 102 | 103 | ## Further Reading 104 | 105 | - [about `waitFor`](https://testing-library.com/docs/dom-testing-library/api-async#waitfor) 106 | - [about `userEvent`](https://github.com/testing-library/user-event) 107 | - [about `fireEvent`](https://testing-library.com/docs/dom-testing-library/api-events) 108 | - [inspiration for this rule](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#performing-side-effects-in-waitfor) 109 | -------------------------------------------------------------------------------- /docs/rules/no-wait-for-snapshot.md: -------------------------------------------------------------------------------- 1 | # Ensures no snapshot is generated inside of a `waitFor` call (`testing-library/no-wait-for-snapshot`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | Ensure that no calls to `toMatchSnapshot` or `toMatchInlineSnapshot` are made from within a `waitFor` method (or any of the other async utility methods). 8 | 9 | ## Rule Details 10 | 11 | The `waitFor()` method runs in a timer loop. So it'll retry every n amount of time. 12 | If a snapshot is generated inside the wait condition, jest will generate one snapshot per each loop. 13 | 14 | The problem then is the amount of loop ran until the condition is met will vary between different computers (or CI machines). This leads to tests that will regenerate a lot of snapshots until the condition is matched when devs run those tests locally updating the snapshots; e.g. devs cannot run `jest -u` locally, or it'll generate a lot of invalid snapshots which will fail during CI. 15 | 16 | Note that this lint rule prevents from generating a snapshot from within any of the [async utility methods](https://testing-library.com/docs/dom-testing-library/api-async). 17 | 18 | Examples of **incorrect** code for this rule: 19 | 20 | ```js 21 | const foo = async () => { 22 | // ... 23 | await waitFor(() => expect(container).toMatchSnapshot()); 24 | // ... 25 | }; 26 | 27 | const bar = async () => { 28 | // ... 29 | await waitFor(() => expect(container).toMatchInlineSnapshot()); 30 | // ... 31 | }; 32 | ``` 33 | 34 | Examples of **correct** code for this rule: 35 | 36 | ```js 37 | const foo = () => { 38 | // ... 39 | expect(container).toMatchSnapshot(); 40 | // ... 41 | }; 42 | 43 | const bar = () => { 44 | // ... 45 | expect(container).toMatchInlineSnapshot(); 46 | // ... 47 | }; 48 | ``` 49 | 50 | ## Further Reading 51 | 52 | - [Async Utilities](https://testing-library.com/docs/dom-testing-library/api-async) 53 | -------------------------------------------------------------------------------- /docs/rules/prefer-find-by.md: -------------------------------------------------------------------------------- 1 | # Suggest using `find(All)By*` query instead of `waitFor` + `get(All)By*` to wait for elements (`testing-library/prefer-find-by`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | `findBy*` queries are a simple combination of `getBy*` queries and `waitFor`. The `findBy*` queries accept the `waitFor` options as the last argument. (i.e. `screen.findByText('text', queryOptions, waitForOptions)`) 10 | 11 | ## Rule details 12 | 13 | This rule aims to use `findBy*` or `findAllBy*` queries to wait for elements, rather than using `waitFor`. 14 | This rule analyzes those cases where `waitFor` is used with just one query method, in the form of an arrow function with only one statement (that is, without a block of statements). Given the callback could be more complex, this rule does not consider function callbacks or arrow functions with blocks of code. 15 | 16 | Examples of **incorrect** code for this rule 17 | 18 | ```js 19 | // arrow functions with one statement, using screen and any sync query method 20 | const submitButton = await waitFor(() => 21 | screen.getByRole('button', { name: /submit/i }) 22 | ); 23 | const submitButton = await waitFor(() => 24 | screen.getAllByTestId('button', { name: /submit/i }) 25 | ); 26 | 27 | // arrow functions with one statement, calling any sync query method 28 | const submitButton = await waitFor(() => 29 | queryByLabel('button', { name: /submit/i }) 30 | ); 31 | 32 | const submitButton = await waitFor(() => 33 | queryAllByText('button', { name: /submit/i }) 34 | ); 35 | 36 | // arrow functions with one statement, calling any sync query method with presence assertion 37 | const submitButton = await waitFor(() => 38 | expect(queryByLabel('button', { name: /submit/i })).toBeInTheDocument() 39 | ); 40 | 41 | const submitButton = await waitFor(() => 42 | expect(queryByLabel('button', { name: /submit/i })).not.toBeFalsy() 43 | ); 44 | 45 | // unnecessary usage of waitFor with findBy*, which already includes waiting logic 46 | await waitFor(async () => { 47 | const button = await findByRole('button', { name: 'Submit' }); 48 | expect(button).toBeInTheDocument(); 49 | }); 50 | ``` 51 | 52 | Examples of **correct** code for this rule: 53 | 54 | ```js 55 | // using findBy* methods 56 | const submitButton = await findByText('foo'); 57 | const submitButton = await screen.findAllByRole('table'); 58 | 59 | // using waitForElementToBeRemoved 60 | await waitForElementToBeRemoved(() => screen.findAllByRole('button')); 61 | await waitForElementToBeRemoved(() => queryAllByLabel('my label')); 62 | await waitForElementToBeRemoved(document.querySelector('foo')); 63 | 64 | // using waitFor with a function 65 | await waitFor(function () { 66 | foo(); 67 | return getByText('name'); 68 | }); 69 | 70 | // passing a reference of a function 71 | function myCustomFunction() { 72 | foo(); 73 | return getByText('name'); 74 | } 75 | await waitFor(myCustomFunction); 76 | 77 | // using waitFor with an arrow function with a code block 78 | await waitFor(() => { 79 | baz(); 80 | return queryAllByText('foo'); 81 | }); 82 | 83 | // using a custom arrow function 84 | await waitFor(() => myCustomFunction()); 85 | 86 | // using expects inside waitFor 87 | await waitFor(() => expect(screen.getByText('bar')).toBeDisabled()); 88 | await waitFor(() => expect(getAllByText('bar')).toBeDisabled()); 89 | ``` 90 | 91 | ## When Not To Use It 92 | 93 | - Not encouraging use of `findBy` shortcut from testing library best practices 94 | 95 | ## Further Reading 96 | 97 | - Documentation for [findBy\* queries](https://testing-library.com/docs/dom-testing-library/api-queries#findby) 98 | 99 | - Common mistakes with RTL, by Kent C. Dodds: [Using waitFor to wait for elements that can be queried with find\*](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#using-waitfor-to-wait-for-elements-that-can-be-queried-with-find) 100 | -------------------------------------------------------------------------------- /docs/rules/prefer-implicit-assert.md: -------------------------------------------------------------------------------- 1 | # Suggest using implicit assertions for getBy* & findBy* queries (`testing-library/prefer-implicit-assert`) 2 | 3 | 4 | 5 | Testing Library `getBy*` & `findBy*` queries throw an error if the element is not 6 | found. Therefore it is not necessary to also assert existence with things like `expect(getBy*.toBeInTheDocument()` or `expect(await findBy*).not.toBeNull()` 7 | 8 | ## Rule Details 9 | 10 | This rule aims to reduce unnecessary assertion's for presence of an element, 11 | when using queries that implicitly fail when said element is not found. 12 | 13 | Examples of **incorrect** code for this rule with the default configuration: 14 | 15 | ```js 16 | // wrapping the getBy or findBy queries within a `expect` and using existence matchers for 17 | // making the assertion is not necessary 18 | expect(getByText('foo')).toBeInTheDocument(); 19 | expect(await findByText('foo')).toBeInTheDocument(); 20 | 21 | expect(getByText('foo')).toBeDefined(); 22 | expect(await findByText('foo')).toBeDefined(); 23 | 24 | const utils = render(); 25 | expect(utils.getByText('foo')).toBeInTheDocument(); 26 | expect(await utils.findByText('foo')).toBeInTheDocument(); 27 | 28 | expect(await findByText('foo')).not.toBeNull(); 29 | expect(await findByText('foo')).not.toBeUndefined(); 30 | ``` 31 | 32 | Examples of **correct** code for this rule with the default configuration: 33 | 34 | ```js 35 | getByText('foo'); 36 | await findByText('foo'); 37 | 38 | const utils = render(); 39 | utils.getByText('foo'); 40 | await utils.findByText('foo'); 41 | 42 | // When using queryBy* queries these do not implicitly fail therefore you should explicitly check if your elements exist or not 43 | expect(queryByText('foo')).toBeInTheDocument(); 44 | expect(queryByText('foo')).not.toBeInTheDocument(); 45 | ``` 46 | 47 | ## When Not To Use It 48 | 49 | If you prefer to use `getBy*` & `findBy*` queries with explicitly asserting existence of elements, then this rule is not recommended. Instead check out this rule [prefer-explicit-assert](https://github.com/testing-library/eslint-plugin-testing-library/blob/main/docs/rules/prefer-explicit-assert.md) 50 | 51 | - Never use both `prefer-implicit-assert` & `prefer-explicit-assert` choose one. 52 | - This library recommends `prefer-explicit-assert` to make it more clear to readers that it is not just a query without an assertion, but that it is checking for existence of an element 53 | 54 | ## Further Reading 55 | 56 | - [getBy query](https://testing-library.com/docs/dom-testing-library/api-queries#getby) 57 | - [findBy query](https://testing-library.com/docs/dom-testing-library/api-queries#findBy) 58 | -------------------------------------------------------------------------------- /docs/rules/prefer-query-by-disappearance.md: -------------------------------------------------------------------------------- 1 | # Suggest using `queryBy*` queries when waiting for disappearance (`testing-library/prefer-query-by-disappearance`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule enforces using `queryBy*` queries when waiting for disappearance with `waitForElementToBeRemoved`. 10 | 11 | Using `queryBy*` queries in a `waitForElementToBeRemoved` yields more descriptive error messages and helps to achieve more consistency in a codebase. 12 | 13 | ```js 14 | // TestingLibraryElementError: Unable to find an element by: [data-testid="loader"] 15 | await waitForElementToBeRemoved(screen.getByTestId('loader')); 16 | 17 | // The element(s) given to waitForElementToBeRemoved are already removed. 18 | // waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal. 19 | await waitForElementToBeRemoved(screen.queryByTestId('loader')); 20 | ``` 21 | 22 | Example of **incorrect** code for this rule: 23 | 24 | ```js 25 | await waitForElementToBeRemoved(() => screen.getByText('hello')); 26 | await waitForElementToBeRemoved(() => screen.findByText('hello')); 27 | 28 | await waitForElementToBeRemoved(screen.getByText('hello')); 29 | await waitForElementToBeRemoved(screen.findByText('hello')); 30 | ``` 31 | 32 | Examples of **correct** code for this rule: 33 | 34 | ```js 35 | await waitForElementToBeRemoved(() => screen.queryByText('hello')); 36 | await waitForElementToBeRemoved(screen.queryByText('hello')); 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/rules/prefer-query-matchers.md: -------------------------------------------------------------------------------- 1 | # Ensure the configured `get*`/`query*` query is used with the corresponding matchers (`testing-library/prefer-query-matchers`) 2 | 3 | 4 | 5 | The (DOM) Testing Library allows to query DOM elements using different types of queries such as `get*` and `query*`. Using `get*` throws an error in case the element is not found, while `query*` returns null instead of throwing (or empty array for `queryAllBy*` ones). 6 | 7 | It may be helpful to ensure that either `get*` or `query*` are always used for a given matcher. For example, `.toBeVisible()` and the negation `.not.toBeVisible()` both assume that an element exists in the DOM and will error if not. Using `get*` with `.toBeVisible()` ensures that if the element is not found the error thrown will offer better info than with `query*`. 8 | 9 | ## Rule details 10 | 11 | This rule must be configured with a list of `validEntries`: for a given matcher, is `get*` or `query*` required. 12 | 13 | Assuming the following configuration: 14 | 15 | ```json 16 | { 17 | "testing-library/prefer-query-matchers": [ 18 | 2, 19 | { 20 | "validEntries": [{ "matcher": "toBeVisible", "query": "get" }] 21 | } 22 | ] 23 | } 24 | ``` 25 | 26 | Examples of **incorrect** code for this rule with the above configuration: 27 | 28 | ```js 29 | test('some test', () => { 30 | render(); 31 | 32 | // use configured matcher with the disallowed `query*` 33 | expect(screen.queryByText('button')).toBeVisible(); 34 | expect(screen.queryByText('button')).not.toBeVisible(); 35 | expect(screen.queryAllByText('button')[0]).toBeVisible(); 36 | expect(screen.queryAllByText('button')[0]).not.toBeVisible(); 37 | }); 38 | ``` 39 | 40 | Examples of **correct** code for this rule: 41 | 42 | ```js 43 | test('some test', async () => { 44 | render(); 45 | // use configured matcher with the allowed `get*` 46 | expect(screen.getByText('button')).toBeVisible(); 47 | expect(screen.getByText('button')).not.toBeVisible(); 48 | expect(screen.getAllByText('button')[0]).toBeVisible(); 49 | expect(screen.getAllByText('button')[0]).not.toBeVisible(); 50 | 51 | // use an unconfigured matcher with either `get* or `query* 52 | expect(screen.getByText('button')).toBeEnabled(); 53 | expect(screen.getAllByText('checkbox')[0]).not.toBeChecked(); 54 | expect(screen.queryByText('button')).toHaveFocus(); 55 | expect(screen.queryAllByText('button')[0]).not.toMatchMyCustomMatcher(); 56 | 57 | // `findBy*` queries are out of the scope for this rule 58 | const button = await screen.findByText('submit'); 59 | expect(button).toBeVisible(); 60 | }); 61 | ``` 62 | 63 | ## Options 64 | 65 | | Option | Required | Default | Details | 66 | | -------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 67 | | `validEntries` | No | `[]` | A list of objects with a `matcher` property (the name of any matcher, such as "toBeVisible") and a `query` property (either "get" or "query"). Indicates whether `get*` or `query*` are allowed with this matcher. | 68 | 69 | ## Example 70 | 71 | ```json 72 | { 73 | "testing-library/prefer-query-matchers": [ 74 | 2, 75 | { 76 | "validEntries": [{ "matcher": "toBeVisible", "query": "get" }] 77 | } 78 | ] 79 | } 80 | ``` 81 | 82 | ## Further Reading 83 | 84 | - [Testing Library queries cheatsheet](https://testing-library.com/docs/dom-testing-library/cheatsheet#queries) 85 | - [jest-dom note about using `getBy` within assertions](https://testing-library.com/docs/ecosystem-jest-dom) 86 | -------------------------------------------------------------------------------- /docs/rules/prefer-screen-queries.md: -------------------------------------------------------------------------------- 1 | # Suggest using `screen` while querying (`testing-library/prefer-screen-queries`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | DOM Testing Library (and other Testing Library frameworks built on top of it) exports a `screen` object which has every query (and a `debug` method). This works better with autocomplete and makes each test a little simpler to write and maintain. 10 | 11 | This rule aims to force writing tests using built-in queries directly from `screen` object rather than destructuring them from `render` result. Given the screen component does not expose utility methods such as `rerender()` or the `container` property, it is correct to use the `render` returned value in those scenarios. 12 | 13 | However, there are 3 exceptions when this rule won't suggest using `screen` for querying: 14 | 15 | 1. You are using a query chained to `within` 16 | 2. You are using custom queries, so you can't access them through `screen` 17 | 3. You are setting the `container` or `baseElement`, so you need to use the queries returned from `render` 18 | 19 | Examples of **incorrect** code for this rule: 20 | 21 | ```js 22 | // calling a query from the `render` method 23 | const { getByText } = render(); 24 | getByText('foo'); 25 | 26 | // calling a query from a variable returned from a `render` method 27 | const utils = render(); 28 | utils.getByText('foo'); 29 | 30 | // using after render 31 | render().getByText('foo'); 32 | 33 | // calling a query from a custom `render` method that returns an array 34 | const [getByText] = myCustomRender(); 35 | getByText('foo'); 36 | ``` 37 | 38 | Examples of **correct** code for this rule: 39 | 40 | ```js 41 | import { render, screen, within } from '@testing-library/any-framework'; 42 | 43 | // calling a query from the `screen` object 44 | render(); 45 | screen.getByText('foo'); 46 | 47 | // using after within clause 48 | within(screen.getByTestId('section')).getByText('foo'); 49 | 50 | // calling a query method returned from a within call 51 | const { getByText } = within(screen.getByText('foo')); 52 | getByText('foo'); 53 | 54 | // calling a method directly from a variable created by within 55 | const myWithinVariable = within(screen.getByText('foo')); 56 | myWithinVariable.getByText('foo'); 57 | 58 | // accessing the container and the base element 59 | const utils = render(baz); 60 | utils.container.querySelector('foo'); 61 | utils.baseElement.querySelector('foo'); 62 | 63 | // calling the utilities function 64 | const utils = render(); 65 | utils.rerender(); 66 | utils.unmount(); 67 | utils.asFragment(); 68 | 69 | // the same functions, but called from destructuring 70 | const { rerender, unmount, asFragment } = render(); 71 | rerender(); 72 | asFragment(); 73 | unmount(); 74 | 75 | // using baseElement 76 | const { getByText } = render(, { baseElement: treeA }); 77 | // using container 78 | const { getAllByText } = render(, { container: treeA }); 79 | 80 | // querying with a custom query imported from its own module 81 | import { getByIcon } from 'custom-queries'; 82 | const element = getByIcon('search'); 83 | 84 | // querying with a custom query returned from `render` 85 | const { getByIcon } = render(); 86 | const element = getByIcon('search'); 87 | ``` 88 | 89 | ## Further Reading 90 | 91 | - [Common mistakes with React Testing Library - Not using `screen`](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#not-using-screen) 92 | - [`screen` documentation](https://testing-library.com/docs/queries/about#screen) 93 | - [Advanced - Custom Queries](https://testing-library.com/docs/dom-testing-library/api-custom-queries/) 94 | - [React Testing Library - Add custom queries](https://testing-library.com/docs/react-testing-library/setup/#add-custom-queries) 95 | -------------------------------------------------------------------------------- /docs/rules/render-result-naming-convention.md: -------------------------------------------------------------------------------- 1 | # Enforce a valid naming for return value from `render` (`testing-library/render-result-naming-convention`) 2 | 3 | 💼 This rule is enabled in the following configs: `angular`, `marko`, `react`, `svelte`, `vue`. 4 | 5 | 6 | 7 | > The name `wrapper` is old cruft from `enzyme` and we don't need that here. The return value from `render` is not "wrapping" anything. It's simply a collection of utilities that you should actually not often need anyway. 8 | 9 | ## Rule Details 10 | 11 | This rule aims to ensure the return value from `render` is named properly. 12 | 13 | Ideally, you should destructure the minimum utils that you need from `render`, combined with using queries from [`screen` object](https://github.com/testing-library/eslint-plugin-testing-library/blob/master/docs/rules/prefer-screen-queries.md). In case you need to save the collection of utils returned in a variable, its name should be either `view` or `utils`, as `render` is not wrapping anything: it's just returning a collection of utilities. Every other name for that variable will be considered invalid. 14 | 15 | To sum up these rules, the allowed naming convention for return value from `render` is: 16 | 17 | - destructuring 18 | - `view` 19 | - `utils` 20 | 21 | Examples of **incorrect** code for this rule: 22 | 23 | ```javascript 24 | import { render } from '@testing-library/framework'; 25 | 26 | // ... 27 | 28 | // return value from `render` shouldn't be kept in a var called "wrapper" 29 | const wrapper = render(); 30 | ``` 31 | 32 | ```javascript 33 | import { render } from '@testing-library/framework'; 34 | 35 | // ... 36 | 37 | // return value from `render` shouldn't be kept in a var called "component" 38 | const component = render(); 39 | ``` 40 | 41 | ```javascript 42 | import { render } from '@testing-library/framework'; 43 | 44 | // ... 45 | 46 | // to sum up: return value from `render` shouldn't be kept in a var called other than "view" or "utils" 47 | const somethingElse = render(); 48 | ``` 49 | 50 | Examples of **correct** code for this rule: 51 | 52 | ```javascript 53 | import { render } from '@testing-library/framework'; 54 | 55 | // ... 56 | 57 | // destructuring return value from `render` is correct 58 | const { unmount, rerender } = render(); 59 | ``` 60 | 61 | ```javascript 62 | import { render } from '@testing-library/framework'; 63 | 64 | // ... 65 | 66 | // keeping return value from `render` in a var called "view" is correct 67 | const view = render(); 68 | ``` 69 | 70 | ```javascript 71 | import { render } from '@testing-library/framework'; 72 | 73 | // ... 74 | 75 | // keeping return value from `render` in a var called "utils" is correct 76 | const utils = render(); 77 | ``` 78 | 79 | ## Further Reading 80 | 81 | - [Common Mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#using-wrapper-as-the-variable-name-for-the-return-value-from-render) 82 | - [`render` Result](https://testing-library.com/docs/react-testing-library/api#render-result) 83 | -------------------------------------------------------------------------------- /docs/semantic-versioning-policy.md: -------------------------------------------------------------------------------- 1 | # Semantic Versioning Policy 2 | 3 | `eslint-plugin-testing-library` follows [Semantic Versioning](https://semver.org/). However, for [the same reason as ESLint itself](https://github.com/eslint/eslint#semantic-versioning-policy), it's not always clear when a minor or major version bump occurs. To help clarify this for everyone, we've defined the following Semantic Versioning Policy for this ESLint plugin: 4 | 5 | - Patch release (intended to not break your lint build) 6 | - A bug fix in a rule that results in `eslint-plugin-testing-library` reporting fewer errors. 7 | - A bug fix to the core. 8 | - Re-releasing after a failed release (i.e., publishing a release that doesn't work for anyone). 9 | - A dependency gets updated 10 | - Minor release (might break your lint build) 11 | - A bug fix in a rule that results in `eslint-plugin-testing-library` reporting more errors. 12 | - A new rule is created. 13 | - A new option to an existing rule that does not result in ESLint reporting more errors by default. 14 | - A new option to an existing rule that might result in ESLint reporting more errors by default. 15 | - A new addition to an existing rule to support a newly-added Testing Library feature that will result in `eslint-plugin-testing-library` reporting more errors by default. 16 | - An existing rule is deprecated. 17 | - New capabilities to the public API are added (new classes, new methods, new arguments to existing methods, etc.). 18 | - A new shareable configuration is created. 19 | - An existing shareable configuration is updated and will result in strictly fewer errors (e.g., rule removals). 20 | - Support for a Node major version is added. 21 | - Major release (likely to break your lint build) 22 | - An existing shareable configuration is updated and may result in new errors (e.g., rule additions, most rule option updates). 23 | - Part of the public API is removed or changed in an incompatible way. The public API includes: 24 | - Rule schemas 25 | - Configuration schema 26 | - Support for a Node major version is dropped. 27 | 28 | According to our policy, any minor update may report more errors than the previous release (ex: from a bug fix). As such, we recommend using the tilde (`~`) in `package.json` e.g. `"eslint-plugin-testing-library": "~4.3.0"` to guarantee the results of your builds. 29 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import type { Linter, Rule } from 'eslint'; 2 | 3 | declare const plugin: { 4 | meta: { 5 | name: string; 6 | version: string; 7 | }; 8 | configs: { 9 | angular: Linter.LegacyConfig; 10 | dom: Linter.LegacyConfig; 11 | marko: Linter.LegacyConfig; 12 | react: Linter.LegacyConfig; 13 | svelte: Linter.LegacyConfig; 14 | vue: Linter.LegacyConfig; 15 | 'flat/angular': Linter.FlatConfig; 16 | 'flat/dom': Linter.FlatConfig; 17 | 'flat/marko': Linter.FlatConfig; 18 | 'flat/react': Linter.FlatConfig; 19 | 'flat/svelte': Linter.FlatConfig; 20 | 'flat/vue': Linter.FlatConfig; 21 | }; 22 | rules: { 23 | [key: string]: Rule.RuleModule; 24 | }; 25 | }; 26 | 27 | export = plugin; 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/tests/**/*.test.ts'], 3 | transform: { 4 | '^.+\\.ts$': '@swc/jest', 5 | }, 6 | coverageThreshold: { 7 | global: { 8 | branches: 90, 9 | functions: 90, 10 | lines: 90, 11 | statements: 90, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /lib/configs/angular.ts: -------------------------------------------------------------------------------- 1 | // THIS CODE WAS AUTOMATICALLY GENERATED 2 | // DO NOT EDIT THIS CODE BY HAND 3 | // YOU CAN REGENERATE IT USING pnpm run generate:configs 4 | 5 | export = { 6 | plugins: ['testing-library'], 7 | rules: { 8 | 'testing-library/await-async-events': [ 9 | 'error', 10 | { eventModule: 'userEvent' }, 11 | ], 12 | 'testing-library/await-async-queries': 'error', 13 | 'testing-library/await-async-utils': 'error', 14 | 'testing-library/no-await-sync-events': [ 15 | 'error', 16 | { eventModules: ['fire-event'] }, 17 | ], 18 | 'testing-library/no-await-sync-queries': 'error', 19 | 'testing-library/no-container': 'error', 20 | 'testing-library/no-debugging-utils': 'warn', 21 | 'testing-library/no-dom-import': ['error', 'angular'], 22 | 'testing-library/no-global-regexp-flag-in-query': 'error', 23 | 'testing-library/no-node-access': 'error', 24 | 'testing-library/no-promise-in-fire-event': 'error', 25 | 'testing-library/no-render-in-lifecycle': 'error', 26 | 'testing-library/no-wait-for-multiple-assertions': 'error', 27 | 'testing-library/no-wait-for-side-effects': 'error', 28 | 'testing-library/no-wait-for-snapshot': 'error', 29 | 'testing-library/prefer-find-by': 'error', 30 | 'testing-library/prefer-presence-queries': 'error', 31 | 'testing-library/prefer-query-by-disappearance': 'error', 32 | 'testing-library/prefer-screen-queries': 'error', 33 | 'testing-library/render-result-naming-convention': 'error', 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /lib/configs/dom.ts: -------------------------------------------------------------------------------- 1 | // THIS CODE WAS AUTOMATICALLY GENERATED 2 | // DO NOT EDIT THIS CODE BY HAND 3 | // YOU CAN REGENERATE IT USING pnpm run generate:configs 4 | 5 | export = { 6 | plugins: ['testing-library'], 7 | rules: { 8 | 'testing-library/await-async-events': [ 9 | 'error', 10 | { eventModule: 'userEvent' }, 11 | ], 12 | 'testing-library/await-async-queries': 'error', 13 | 'testing-library/await-async-utils': 'error', 14 | 'testing-library/no-await-sync-events': [ 15 | 'error', 16 | { eventModules: ['fire-event'] }, 17 | ], 18 | 'testing-library/no-await-sync-queries': 'error', 19 | 'testing-library/no-global-regexp-flag-in-query': 'error', 20 | 'testing-library/no-node-access': 'error', 21 | 'testing-library/no-promise-in-fire-event': 'error', 22 | 'testing-library/no-wait-for-multiple-assertions': 'error', 23 | 'testing-library/no-wait-for-side-effects': 'error', 24 | 'testing-library/no-wait-for-snapshot': 'error', 25 | 'testing-library/prefer-find-by': 'error', 26 | 'testing-library/prefer-presence-queries': 'error', 27 | 'testing-library/prefer-query-by-disappearance': 'error', 28 | 'testing-library/prefer-screen-queries': 'error', 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /lib/configs/index.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import type { TSESLint } from '@typescript-eslint/utils'; 4 | 5 | import { 6 | importDefault, 7 | SUPPORTED_TESTING_FRAMEWORKS, 8 | SupportedTestingFramework, 9 | } from '../utils'; 10 | 11 | const configsDir = __dirname; 12 | 13 | const getConfigForFramework = (framework: SupportedTestingFramework) => 14 | importDefault(join(configsDir, framework)); 15 | 16 | export default SUPPORTED_TESTING_FRAMEWORKS.reduce( 17 | (allConfigs, framework) => ({ 18 | ...allConfigs, 19 | [framework]: getConfigForFramework(framework), 20 | }), 21 | {} 22 | ) as Record; 23 | -------------------------------------------------------------------------------- /lib/configs/marko.ts: -------------------------------------------------------------------------------- 1 | // THIS CODE WAS AUTOMATICALLY GENERATED 2 | // DO NOT EDIT THIS CODE BY HAND 3 | // YOU CAN REGENERATE IT USING pnpm run generate:configs 4 | 5 | export = { 6 | plugins: ['testing-library'], 7 | rules: { 8 | 'testing-library/await-async-events': [ 9 | 'error', 10 | { eventModule: ['fireEvent', 'userEvent'] }, 11 | ], 12 | 'testing-library/await-async-queries': 'error', 13 | 'testing-library/await-async-utils': 'error', 14 | 'testing-library/no-await-sync-queries': 'error', 15 | 'testing-library/no-container': 'error', 16 | 'testing-library/no-debugging-utils': 'warn', 17 | 'testing-library/no-dom-import': ['error', 'marko'], 18 | 'testing-library/no-global-regexp-flag-in-query': 'error', 19 | 'testing-library/no-node-access': 'error', 20 | 'testing-library/no-promise-in-fire-event': 'error', 21 | 'testing-library/no-render-in-lifecycle': 'error', 22 | 'testing-library/no-unnecessary-act': 'error', 23 | 'testing-library/no-wait-for-multiple-assertions': 'error', 24 | 'testing-library/no-wait-for-side-effects': 'error', 25 | 'testing-library/no-wait-for-snapshot': 'error', 26 | 'testing-library/prefer-find-by': 'error', 27 | 'testing-library/prefer-presence-queries': 'error', 28 | 'testing-library/prefer-query-by-disappearance': 'error', 29 | 'testing-library/prefer-screen-queries': 'error', 30 | 'testing-library/render-result-naming-convention': 'error', 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /lib/configs/react.ts: -------------------------------------------------------------------------------- 1 | // THIS CODE WAS AUTOMATICALLY GENERATED 2 | // DO NOT EDIT THIS CODE BY HAND 3 | // YOU CAN REGENERATE IT USING pnpm run generate:configs 4 | 5 | export = { 6 | plugins: ['testing-library'], 7 | rules: { 8 | 'testing-library/await-async-events': [ 9 | 'error', 10 | { eventModule: 'userEvent' }, 11 | ], 12 | 'testing-library/await-async-queries': 'error', 13 | 'testing-library/await-async-utils': 'error', 14 | 'testing-library/no-await-sync-events': [ 15 | 'error', 16 | { eventModules: ['fire-event'] }, 17 | ], 18 | 'testing-library/no-await-sync-queries': 'error', 19 | 'testing-library/no-container': 'error', 20 | 'testing-library/no-debugging-utils': 'warn', 21 | 'testing-library/no-dom-import': ['error', 'react'], 22 | 'testing-library/no-global-regexp-flag-in-query': 'error', 23 | 'testing-library/no-manual-cleanup': 'error', 24 | 'testing-library/no-node-access': 'error', 25 | 'testing-library/no-promise-in-fire-event': 'error', 26 | 'testing-library/no-render-in-lifecycle': 'error', 27 | 'testing-library/no-unnecessary-act': 'error', 28 | 'testing-library/no-wait-for-multiple-assertions': 'error', 29 | 'testing-library/no-wait-for-side-effects': 'error', 30 | 'testing-library/no-wait-for-snapshot': 'error', 31 | 'testing-library/prefer-find-by': 'error', 32 | 'testing-library/prefer-presence-queries': 'error', 33 | 'testing-library/prefer-query-by-disappearance': 'error', 34 | 'testing-library/prefer-screen-queries': 'error', 35 | 'testing-library/render-result-naming-convention': 'error', 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /lib/configs/svelte.ts: -------------------------------------------------------------------------------- 1 | // THIS CODE WAS AUTOMATICALLY GENERATED 2 | // DO NOT EDIT THIS CODE BY HAND 3 | // YOU CAN REGENERATE IT USING pnpm run generate:configs 4 | 5 | export = { 6 | plugins: ['testing-library'], 7 | rules: { 8 | 'testing-library/await-async-events': [ 9 | 'error', 10 | { eventModule: ['fireEvent', 'userEvent'] }, 11 | ], 12 | 'testing-library/await-async-queries': 'error', 13 | 'testing-library/await-async-utils': 'error', 14 | 'testing-library/no-await-sync-queries': 'error', 15 | 'testing-library/no-container': 'error', 16 | 'testing-library/no-debugging-utils': 'warn', 17 | 'testing-library/no-dom-import': ['error', 'svelte'], 18 | 'testing-library/no-global-regexp-flag-in-query': 'error', 19 | 'testing-library/no-manual-cleanup': 'error', 20 | 'testing-library/no-node-access': 'error', 21 | 'testing-library/no-promise-in-fire-event': 'error', 22 | 'testing-library/no-render-in-lifecycle': 'error', 23 | 'testing-library/no-wait-for-multiple-assertions': 'error', 24 | 'testing-library/no-wait-for-side-effects': 'error', 25 | 'testing-library/no-wait-for-snapshot': 'error', 26 | 'testing-library/prefer-find-by': 'error', 27 | 'testing-library/prefer-presence-queries': 'error', 28 | 'testing-library/prefer-query-by-disappearance': 'error', 29 | 'testing-library/prefer-screen-queries': 'error', 30 | 'testing-library/render-result-naming-convention': 'error', 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /lib/configs/vue.ts: -------------------------------------------------------------------------------- 1 | // THIS CODE WAS AUTOMATICALLY GENERATED 2 | // DO NOT EDIT THIS CODE BY HAND 3 | // YOU CAN REGENERATE IT USING pnpm run generate:configs 4 | 5 | export = { 6 | plugins: ['testing-library'], 7 | rules: { 8 | 'testing-library/await-async-events': [ 9 | 'error', 10 | { eventModule: ['fireEvent', 'userEvent'] }, 11 | ], 12 | 'testing-library/await-async-queries': 'error', 13 | 'testing-library/await-async-utils': 'error', 14 | 'testing-library/no-await-sync-queries': 'error', 15 | 'testing-library/no-container': 'error', 16 | 'testing-library/no-debugging-utils': 'warn', 17 | 'testing-library/no-dom-import': ['error', 'vue'], 18 | 'testing-library/no-global-regexp-flag-in-query': 'error', 19 | 'testing-library/no-manual-cleanup': 'error', 20 | 'testing-library/no-node-access': 'error', 21 | 'testing-library/no-promise-in-fire-event': 'error', 22 | 'testing-library/no-render-in-lifecycle': 'error', 23 | 'testing-library/no-wait-for-multiple-assertions': 'error', 24 | 'testing-library/no-wait-for-side-effects': 'error', 25 | 'testing-library/no-wait-for-snapshot': 'error', 26 | 'testing-library/prefer-find-by': 'error', 27 | 'testing-library/prefer-presence-queries': 'error', 28 | 'testing-library/prefer-query-by-disappearance': 'error', 29 | 'testing-library/prefer-screen-queries': 'error', 30 | 'testing-library/render-result-naming-convention': 'error', 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /lib/create-testing-library-rule/index.ts: -------------------------------------------------------------------------------- 1 | import { ESLintUtils } from '@typescript-eslint/utils'; 2 | 3 | import { 4 | getDocsUrl, 5 | TestingLibraryPluginDocs, 6 | TestingLibraryPluginRuleModule, 7 | } from '../utils'; 8 | 9 | import { 10 | DetectionOptions, 11 | detectTestingLibraryUtils, 12 | EnhancedRuleCreate, 13 | } from './detect-testing-library-utils'; 14 | 15 | export const createTestingLibraryRule = < 16 | TOptions extends readonly unknown[], 17 | TMessageIds extends string, 18 | >({ 19 | create, 20 | detectionOptions = {}, 21 | ...remainingConfig 22 | }: Readonly< 23 | Omit< 24 | ESLintUtils.RuleWithMetaAndName< 25 | TOptions, 26 | TMessageIds, 27 | TestingLibraryPluginDocs 28 | >, 29 | 'create' 30 | > & { 31 | create: EnhancedRuleCreate; 32 | detectionOptions?: Partial; 33 | } 34 | >): TestingLibraryPluginRuleModule => { 35 | const rule = ESLintUtils.RuleCreator>( 36 | getDocsUrl 37 | )({ 38 | ...remainingConfig, 39 | create: detectTestingLibraryUtils( 40 | create, 41 | detectionOptions 42 | ), 43 | }); 44 | const { docs } = rule.meta; 45 | if (docs === undefined) { 46 | throw new Error('Rule metadata must contain `docs` property'); 47 | } 48 | 49 | return { ...rule, meta: { ...rule.meta, docs } }; 50 | }; 51 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import type { TSESLint } from '@typescript-eslint/utils'; 2 | 3 | import configs from './configs'; 4 | import rules from './rules'; 5 | import { SupportedTestingFramework } from './utils'; 6 | 7 | // we can't natively import package.json as tsc will copy it into dist/ 8 | const { 9 | name: packageName, 10 | version: packageVersion, 11 | // eslint-disable-next-line @typescript-eslint/no-require-imports 12 | } = require('../package.json') as { name: string; version: string }; 13 | 14 | const plugin = { 15 | meta: { 16 | name: packageName, 17 | version: packageVersion, 18 | }, 19 | // ugly cast for now to keep TypeScript happy since 20 | // we don't have types for flat config yet 21 | configs: {} as Record< 22 | SupportedTestingFramework | `flat/${SupportedTestingFramework}`, 23 | TSESLint.SharedConfig.RulesRecord 24 | >, 25 | rules, 26 | }; 27 | 28 | plugin.configs = { 29 | ...configs, 30 | ...(Object.fromEntries( 31 | Object.entries(configs).map(([framework, config]) => [ 32 | `flat/${framework}`, 33 | { 34 | plugins: { 'testing-library': plugin }, 35 | rules: config.rules, 36 | }, 37 | ]) 38 | ) as unknown as Record< 39 | `flat/${SupportedTestingFramework}`, 40 | TSESLint.SharedConfig.RulesRecord & { plugins: unknown } 41 | >), 42 | }; 43 | 44 | export = plugin; 45 | -------------------------------------------------------------------------------- /lib/node-utils/is-node-of-type.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES, ASTUtils } from '@typescript-eslint/utils'; 2 | 3 | export const isArrayExpression = ASTUtils.isNodeOfType( 4 | AST_NODE_TYPES.ArrayExpression 5 | ); 6 | export const isArrowFunctionExpression = ASTUtils.isNodeOfType( 7 | AST_NODE_TYPES.ArrowFunctionExpression 8 | ); 9 | export const isBlockStatement = ASTUtils.isNodeOfType( 10 | AST_NODE_TYPES.BlockStatement 11 | ); 12 | export const isCallExpression = ASTUtils.isNodeOfType( 13 | AST_NODE_TYPES.CallExpression 14 | ); 15 | export const isExpressionStatement = ASTUtils.isNodeOfType( 16 | AST_NODE_TYPES.ExpressionStatement 17 | ); 18 | export const isVariableDeclaration = ASTUtils.isNodeOfType( 19 | AST_NODE_TYPES.VariableDeclaration 20 | ); 21 | export const isAssignmentExpression = ASTUtils.isNodeOfType( 22 | AST_NODE_TYPES.AssignmentExpression 23 | ); 24 | export const isSequenceExpression = ASTUtils.isNodeOfType( 25 | AST_NODE_TYPES.SequenceExpression 26 | ); 27 | export const isImportDeclaration = ASTUtils.isNodeOfType( 28 | AST_NODE_TYPES.ImportDeclaration 29 | ); 30 | export const isImportDefaultSpecifier = ASTUtils.isNodeOfType( 31 | AST_NODE_TYPES.ImportDefaultSpecifier 32 | ); 33 | export const isImportNamespaceSpecifier = ASTUtils.isNodeOfType( 34 | AST_NODE_TYPES.ImportNamespaceSpecifier 35 | ); 36 | export const isImportSpecifier = ASTUtils.isNodeOfType( 37 | AST_NODE_TYPES.ImportSpecifier 38 | ); 39 | export const isJSXAttribute = ASTUtils.isNodeOfType( 40 | AST_NODE_TYPES.JSXAttribute 41 | ); 42 | export const isLiteral = ASTUtils.isNodeOfType(AST_NODE_TYPES.Literal); 43 | export const isMemberExpression = ASTUtils.isNodeOfType( 44 | AST_NODE_TYPES.MemberExpression 45 | ); 46 | export const isNewExpression = ASTUtils.isNodeOfType( 47 | AST_NODE_TYPES.NewExpression 48 | ); 49 | export const isObjectExpression = ASTUtils.isNodeOfType( 50 | AST_NODE_TYPES.ObjectExpression 51 | ); 52 | export const isObjectPattern = ASTUtils.isNodeOfType( 53 | AST_NODE_TYPES.ObjectPattern 54 | ); 55 | export const isProperty = ASTUtils.isNodeOfType(AST_NODE_TYPES.Property); 56 | export const isReturnStatement = ASTUtils.isNodeOfType( 57 | AST_NODE_TYPES.ReturnStatement 58 | ); 59 | export const isFunctionExpression = ASTUtils.isNodeOfType( 60 | AST_NODE_TYPES.FunctionExpression 61 | ); 62 | export const isFunctionDeclaration = ASTUtils.isNodeOfType( 63 | AST_NODE_TYPES.FunctionDeclaration 64 | ); 65 | -------------------------------------------------------------------------------- /lib/rules/await-async-queries.ts: -------------------------------------------------------------------------------- 1 | import { ASTUtils, TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { 5 | findClosestCallExpressionNode, 6 | getDeepestIdentifierNode, 7 | getFunctionName, 8 | getInnermostReturningFunction, 9 | getVariableReferences, 10 | isPromiseHandled, 11 | } from '../node-utils'; 12 | 13 | export const RULE_NAME = 'await-async-queries'; 14 | export type MessageIds = 'asyncQueryWrapper' | 'awaitAsyncQuery'; 15 | type Options = []; 16 | 17 | export default createTestingLibraryRule({ 18 | name: RULE_NAME, 19 | meta: { 20 | type: 'problem', 21 | docs: { 22 | description: 'Enforce promises from async queries to be handled', 23 | recommendedConfig: { 24 | dom: 'error', 25 | angular: 'error', 26 | react: 'error', 27 | vue: 'error', 28 | svelte: 'error', 29 | marko: 'error', 30 | }, 31 | }, 32 | messages: { 33 | awaitAsyncQuery: 34 | 'promise returned from `{{ name }}` query must be handled', 35 | asyncQueryWrapper: 36 | 'promise returned from `{{ name }}` wrapper over async query must be handled', 37 | }, 38 | schema: [], 39 | }, 40 | defaultOptions: [], 41 | 42 | create(context, _, helpers) { 43 | const functionWrappersNames: string[] = []; 44 | 45 | function detectAsyncQueryWrapper(node: TSESTree.Identifier) { 46 | const innerFunction = getInnermostReturningFunction(context, node); 47 | if (innerFunction) { 48 | functionWrappersNames.push(getFunctionName(innerFunction)); 49 | } 50 | } 51 | 52 | return { 53 | CallExpression(node) { 54 | const identifierNode = getDeepestIdentifierNode(node); 55 | 56 | if (!identifierNode) { 57 | return; 58 | } 59 | 60 | if (helpers.isAsyncQuery(identifierNode)) { 61 | // detect async query used within wrapper function for later analysis 62 | detectAsyncQueryWrapper(identifierNode); 63 | 64 | const closestCallExpressionNode = findClosestCallExpressionNode( 65 | node, 66 | true 67 | ); 68 | 69 | if (!closestCallExpressionNode?.parent) { 70 | return; 71 | } 72 | 73 | const references = getVariableReferences( 74 | context, 75 | closestCallExpressionNode.parent 76 | ); 77 | 78 | // check direct usage of async query: 79 | // const element = await findByRole('button') 80 | if (references.length === 0) { 81 | if (!isPromiseHandled(identifierNode)) { 82 | context.report({ 83 | node: identifierNode, 84 | messageId: 'awaitAsyncQuery', 85 | data: { name: identifierNode.name }, 86 | }); 87 | return; 88 | } 89 | } 90 | 91 | // check references usages of async query: 92 | // const promise = findByRole('button') 93 | // const element = await promise 94 | for (const reference of references) { 95 | if ( 96 | ASTUtils.isIdentifier(reference.identifier) && 97 | !isPromiseHandled(reference.identifier) 98 | ) { 99 | context.report({ 100 | node: identifierNode, 101 | messageId: 'awaitAsyncQuery', 102 | data: { name: identifierNode.name }, 103 | }); 104 | return; 105 | } 106 | } 107 | } else if ( 108 | functionWrappersNames.includes(identifierNode.name) && 109 | !isPromiseHandled(identifierNode) 110 | ) { 111 | // check async queries used within a wrapper previously detected 112 | context.report({ 113 | node: identifierNode, 114 | messageId: 'asyncQueryWrapper', 115 | data: { name: identifierNode.name }, 116 | }); 117 | } 118 | }, 119 | }; 120 | }, 121 | }); 122 | -------------------------------------------------------------------------------- /lib/rules/await-async-utils.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree, ASTUtils } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { 5 | findClosestCallExpressionNode, 6 | getDeepestIdentifierNode, 7 | getFunctionName, 8 | getInnermostReturningFunction, 9 | getVariableReferences, 10 | isObjectPattern, 11 | isPromiseHandled, 12 | isProperty, 13 | } from '../node-utils'; 14 | 15 | export const RULE_NAME = 'await-async-utils'; 16 | export type MessageIds = 'asyncUtilWrapper' | 'awaitAsyncUtil'; 17 | type Options = []; 18 | 19 | export default createTestingLibraryRule({ 20 | name: RULE_NAME, 21 | meta: { 22 | type: 'problem', 23 | docs: { 24 | description: 'Enforce promises from async utils to be awaited properly', 25 | recommendedConfig: { 26 | dom: 'error', 27 | angular: 'error', 28 | react: 'error', 29 | vue: 'error', 30 | svelte: 'error', 31 | marko: 'error', 32 | }, 33 | }, 34 | messages: { 35 | awaitAsyncUtil: 'Promise returned from `{{ name }}` must be handled', 36 | asyncUtilWrapper: 37 | 'Promise returned from {{ name }} wrapper over async util must be handled', 38 | }, 39 | schema: [], 40 | }, 41 | defaultOptions: [], 42 | 43 | create(context, _, helpers) { 44 | const functionWrappersNames: string[] = []; 45 | 46 | function detectAsyncUtilWrapper(node: TSESTree.Identifier) { 47 | const innerFunction = getInnermostReturningFunction(context, node); 48 | if (!innerFunction) { 49 | return; 50 | } 51 | 52 | const functionName = getFunctionName(innerFunction); 53 | if (functionName.length === 0) { 54 | return; 55 | } 56 | 57 | functionWrappersNames.push(functionName); 58 | } 59 | 60 | /* 61 | Example: 62 | `const { myAsyncWrapper: myRenamedValue } = someObject`; 63 | Detects `myRenamedValue` and adds it to the known async wrapper names. 64 | */ 65 | function detectDestructuredAsyncUtilWrapperAliases( 66 | node: TSESTree.ObjectPattern 67 | ) { 68 | for (const property of node.properties) { 69 | if (!isProperty(property)) { 70 | continue; 71 | } 72 | 73 | if ( 74 | !ASTUtils.isIdentifier(property.key) || 75 | !ASTUtils.isIdentifier(property.value) 76 | ) { 77 | continue; 78 | } 79 | 80 | if (functionWrappersNames.includes(property.key.name)) { 81 | const isDestructuredAsyncWrapperPropertyRenamed = 82 | property.key.name !== property.value.name; 83 | 84 | if (isDestructuredAsyncWrapperPropertyRenamed) { 85 | functionWrappersNames.push(property.value.name); 86 | } 87 | } 88 | } 89 | } 90 | 91 | /* 92 | Either we report a direct usage of an async util or a usage of a wrapper 93 | around an async util 94 | */ 95 | const getMessageId = (node: TSESTree.Identifier): MessageIds => { 96 | if (helpers.isAsyncUtil(node)) { 97 | return 'awaitAsyncUtil'; 98 | } 99 | 100 | return 'asyncUtilWrapper'; 101 | }; 102 | 103 | return { 104 | VariableDeclarator(node: TSESTree.VariableDeclarator) { 105 | if (isObjectPattern(node.id)) { 106 | detectDestructuredAsyncUtilWrapperAliases(node.id); 107 | return; 108 | } 109 | 110 | const isAssigningKnownAsyncFunctionWrapper = 111 | ASTUtils.isIdentifier(node.id) && 112 | node.init !== null && 113 | functionWrappersNames.includes( 114 | getDeepestIdentifierNode(node.init)?.name ?? '' 115 | ); 116 | 117 | if (isAssigningKnownAsyncFunctionWrapper) { 118 | functionWrappersNames.push((node.id as TSESTree.Identifier).name); 119 | } 120 | }, 121 | 'CallExpression Identifier'(node: TSESTree.Identifier) { 122 | const isAsyncUtilOrKnownAliasAroundIt = 123 | helpers.isAsyncUtil(node) || 124 | functionWrappersNames.includes(node.name); 125 | if (!isAsyncUtilOrKnownAliasAroundIt) { 126 | return; 127 | } 128 | 129 | // detect async query used within wrapper function for later analysis 130 | if (helpers.isAsyncUtil(node)) { 131 | detectAsyncUtilWrapper(node); 132 | } 133 | 134 | const closestCallExpression = findClosestCallExpressionNode(node, true); 135 | 136 | if (!closestCallExpression?.parent) { 137 | return; 138 | } 139 | 140 | const references = getVariableReferences( 141 | context, 142 | closestCallExpression.parent 143 | ); 144 | 145 | if (references.length === 0) { 146 | if (!isPromiseHandled(node)) { 147 | context.report({ 148 | node, 149 | messageId: getMessageId(node), 150 | data: { 151 | name: node.name, 152 | }, 153 | }); 154 | } 155 | } else { 156 | for (const reference of references) { 157 | const referenceNode = reference.identifier as TSESTree.Identifier; 158 | if (!isPromiseHandled(referenceNode)) { 159 | context.report({ 160 | node, 161 | messageId: getMessageId(node), 162 | data: { 163 | name: node.name, 164 | }, 165 | }); 166 | return; 167 | } 168 | } 169 | } 170 | }, 171 | }; 172 | }, 173 | }); 174 | -------------------------------------------------------------------------------- /lib/rules/consistent-data-testid.ts: -------------------------------------------------------------------------------- 1 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 2 | import { isJSXAttribute, isLiteral } from '../node-utils'; 3 | import { getFilename } from '../utils'; 4 | 5 | export const RULE_NAME = 'consistent-data-testid'; 6 | export type MessageIds = 7 | | 'consistentDataTestId' 8 | | 'consistentDataTestIdCustomMessage'; 9 | export type Options = [ 10 | { 11 | testIdAttribute?: string[] | string; 12 | testIdPattern: string; 13 | customMessage?: string; 14 | }, 15 | ]; 16 | 17 | const FILENAME_PLACEHOLDER = '{fileName}'; 18 | 19 | export default createTestingLibraryRule({ 20 | name: RULE_NAME, 21 | meta: { 22 | type: 'suggestion', 23 | docs: { 24 | description: 'Ensures consistent usage of `data-testid`', 25 | recommendedConfig: { 26 | dom: false, 27 | angular: false, 28 | react: false, 29 | vue: false, 30 | svelte: false, 31 | marko: false, 32 | }, 33 | }, 34 | messages: { 35 | consistentDataTestId: '`{{attr}}` "{{value}}" should match `{{regex}}`', 36 | consistentDataTestIdCustomMessage: '`{{message}}`', 37 | }, 38 | schema: [ 39 | { 40 | type: 'object', 41 | default: {}, 42 | additionalProperties: false, 43 | required: ['testIdPattern'], 44 | properties: { 45 | testIdPattern: { 46 | type: 'string', 47 | }, 48 | testIdAttribute: { 49 | default: 'data-testid', 50 | oneOf: [ 51 | { 52 | type: 'string', 53 | }, 54 | { 55 | type: 'array', 56 | items: { 57 | type: 'string', 58 | }, 59 | }, 60 | ], 61 | }, 62 | customMessage: { 63 | default: undefined, 64 | type: 'string', 65 | }, 66 | }, 67 | }, 68 | ], 69 | }, 70 | defaultOptions: [ 71 | { 72 | testIdPattern: '', 73 | testIdAttribute: 'data-testid', 74 | customMessage: undefined, 75 | }, 76 | ], 77 | detectionOptions: { 78 | skipRuleReportingCheck: true, 79 | }, 80 | 81 | create: (context, [options]) => { 82 | const { testIdPattern, testIdAttribute: attr, customMessage } = options; 83 | 84 | function getFileNameData() { 85 | const splitPath = getFilename(context).split('/'); 86 | const fileNameWithExtension = splitPath.pop() ?? ''; 87 | if ( 88 | fileNameWithExtension.includes('[') || 89 | fileNameWithExtension.includes(']') 90 | ) { 91 | return { fileName: undefined }; 92 | } 93 | const parent = splitPath.pop(); 94 | const fileName = fileNameWithExtension.split('.').shift(); 95 | 96 | return { 97 | fileName: fileName === 'index' ? parent : fileName, 98 | }; 99 | } 100 | 101 | function getTestIdValidator(fileName: string) { 102 | return new RegExp(testIdPattern.replace(FILENAME_PLACEHOLDER, fileName)); 103 | } 104 | 105 | function isTestIdAttribute(name: string): boolean { 106 | if (typeof attr === 'string') { 107 | return attr === name; 108 | } else { 109 | return attr?.includes(name) ?? false; 110 | } 111 | } 112 | 113 | function getErrorMessageId(): MessageIds { 114 | if (customMessage === undefined) { 115 | return 'consistentDataTestId'; 116 | } 117 | 118 | return 'consistentDataTestIdCustomMessage'; 119 | } 120 | 121 | return { 122 | JSXIdentifier: (node) => { 123 | if ( 124 | !node.parent || 125 | !isJSXAttribute(node.parent) || 126 | !isLiteral(node.parent.value) || 127 | !isTestIdAttribute(node.name) 128 | ) { 129 | return; 130 | } 131 | 132 | const value = node.parent.value.value; 133 | const { fileName } = getFileNameData(); 134 | const regex = getTestIdValidator(fileName ?? ''); 135 | 136 | if (value && typeof value === 'string' && !regex.test(value)) { 137 | context.report({ 138 | node, 139 | messageId: getErrorMessageId(), 140 | data: { 141 | attr: node.name, 142 | value, 143 | regex, 144 | message: customMessage, 145 | }, 146 | }); 147 | } 148 | }, 149 | }; 150 | }, 151 | }); 152 | -------------------------------------------------------------------------------- /lib/rules/index.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync } from 'fs'; 2 | import { join, parse } from 'path'; 3 | 4 | import { importDefault, TestingLibraryPluginRuleModule } from '../utils'; 5 | 6 | const rulesDir = __dirname; 7 | const excludedFiles = ['index']; 8 | 9 | export default readdirSync(rulesDir) 10 | .map((rule) => parse(rule).name) 11 | .filter((ruleName) => !excludedFiles.includes(ruleName)) 12 | .reduce>>( 13 | (allRules, ruleName) => ({ 14 | ...allRules, 15 | [ruleName]: importDefault< 16 | TestingLibraryPluginRuleModule 17 | >(join(rulesDir, ruleName)), 18 | }), 19 | {} 20 | ); 21 | -------------------------------------------------------------------------------- /lib/rules/no-await-sync-queries.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { getDeepestIdentifierNode } from '../node-utils'; 5 | 6 | export const RULE_NAME = 'no-await-sync-queries'; 7 | export type MessageIds = 'noAwaitSyncQuery'; 8 | type Options = []; 9 | 10 | export default createTestingLibraryRule({ 11 | name: RULE_NAME, 12 | meta: { 13 | type: 'problem', 14 | docs: { 15 | description: 'Disallow unnecessary `await` for sync queries', 16 | recommendedConfig: { 17 | dom: 'error', 18 | angular: 'error', 19 | react: 'error', 20 | vue: 'error', 21 | svelte: 'error', 22 | marko: 'error', 23 | }, 24 | }, 25 | messages: { 26 | noAwaitSyncQuery: 27 | '`{{ name }}` query is sync so it does not need to be awaited', 28 | }, 29 | schema: [], 30 | }, 31 | defaultOptions: [], 32 | 33 | create(context, _, helpers) { 34 | return { 35 | 'AwaitExpression > CallExpression'(node: TSESTree.CallExpression) { 36 | const deepestIdentifierNode = getDeepestIdentifierNode(node); 37 | 38 | if (!deepestIdentifierNode) { 39 | return; 40 | } 41 | 42 | if (helpers.isSyncQuery(deepestIdentifierNode)) { 43 | context.report({ 44 | node: deepestIdentifierNode, 45 | messageId: 'noAwaitSyncQuery', 46 | data: { 47 | name: deepestIdentifierNode.name, 48 | }, 49 | }); 50 | } 51 | }, 52 | }; 53 | }, 54 | }); 55 | -------------------------------------------------------------------------------- /lib/rules/no-container.ts: -------------------------------------------------------------------------------- 1 | import { ASTUtils, TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { 5 | getDeepestIdentifierNode, 6 | getFunctionName, 7 | getInnermostReturningFunction, 8 | isMemberExpression, 9 | isObjectPattern, 10 | isProperty, 11 | } from '../node-utils'; 12 | 13 | export const RULE_NAME = 'no-container'; 14 | export type MessageIds = 'noContainer'; 15 | type Options = []; 16 | 17 | export default createTestingLibraryRule({ 18 | name: RULE_NAME, 19 | meta: { 20 | type: 'problem', 21 | docs: { 22 | description: 'Disallow the use of `container` methods', 23 | recommendedConfig: { 24 | dom: false, 25 | angular: 'error', 26 | react: 'error', 27 | vue: 'error', 28 | svelte: 'error', 29 | marko: 'error', 30 | }, 31 | }, 32 | messages: { 33 | noContainer: 34 | 'Avoid using container methods. Prefer using the methods from Testing Library, such as "getByRole()"', 35 | }, 36 | schema: [], 37 | }, 38 | defaultOptions: [], 39 | 40 | create(context, _, helpers) { 41 | const destructuredContainerPropNames: string[] = []; 42 | const renderWrapperNames: string[] = []; 43 | let renderResultVarName: string | null = null; 44 | let containerName: string | null = null; 45 | let containerCallsMethod = false; 46 | 47 | function detectRenderWrapper(node: TSESTree.Identifier): void { 48 | const innerFunction = getInnermostReturningFunction(context, node); 49 | 50 | if (innerFunction) { 51 | renderWrapperNames.push(getFunctionName(innerFunction)); 52 | } 53 | } 54 | 55 | function showErrorIfChainedContainerMethod( 56 | innerNode: TSESTree.MemberExpression 57 | ) { 58 | if (isMemberExpression(innerNode)) { 59 | if (ASTUtils.isIdentifier(innerNode.object)) { 60 | const isContainerName = innerNode.object.name === containerName; 61 | 62 | if (isContainerName) { 63 | context.report({ 64 | node: innerNode, 65 | messageId: 'noContainer', 66 | }); 67 | return; 68 | } 69 | 70 | const isRenderWrapper = innerNode.object.name === renderResultVarName; 71 | containerCallsMethod = 72 | ASTUtils.isIdentifier(innerNode.property) && 73 | innerNode.property.name === 'container' && 74 | isRenderWrapper; 75 | 76 | if (containerCallsMethod) { 77 | context.report({ 78 | node: innerNode.property, 79 | messageId: 'noContainer', 80 | }); 81 | return; 82 | } 83 | } 84 | showErrorIfChainedContainerMethod( 85 | innerNode.object as TSESTree.MemberExpression 86 | ); 87 | } 88 | } 89 | 90 | return { 91 | CallExpression(node) { 92 | const callExpressionIdentifier = getDeepestIdentifierNode(node); 93 | 94 | if (!callExpressionIdentifier) { 95 | return; 96 | } 97 | 98 | if (helpers.isRenderUtil(callExpressionIdentifier)) { 99 | detectRenderWrapper(callExpressionIdentifier); 100 | } 101 | 102 | if (isMemberExpression(node.callee)) { 103 | showErrorIfChainedContainerMethod(node.callee); 104 | } else if ( 105 | ASTUtils.isIdentifier(node.callee) && 106 | destructuredContainerPropNames.includes(node.callee.name) 107 | ) { 108 | context.report({ 109 | node, 110 | messageId: 'noContainer', 111 | }); 112 | } 113 | }, 114 | 115 | VariableDeclarator(node) { 116 | if (!node.init) { 117 | return; 118 | } 119 | const initIdentifierNode = getDeepestIdentifierNode(node.init); 120 | 121 | if (!initIdentifierNode) { 122 | return; 123 | } 124 | 125 | const isRenderWrapperVariableDeclarator = renderWrapperNames.includes( 126 | initIdentifierNode.name 127 | ); 128 | 129 | if ( 130 | !helpers.isRenderVariableDeclarator(node) && 131 | !isRenderWrapperVariableDeclarator 132 | ) { 133 | return; 134 | } 135 | 136 | if (isObjectPattern(node.id)) { 137 | const containerIndex = node.id.properties.findIndex( 138 | (property) => 139 | isProperty(property) && 140 | ASTUtils.isIdentifier(property.key) && 141 | property.key.name === 'container' 142 | ); 143 | 144 | const nodeValue = 145 | containerIndex !== -1 && node.id.properties[containerIndex].value; 146 | 147 | if (!nodeValue) { 148 | return; 149 | } 150 | 151 | if (ASTUtils.isIdentifier(nodeValue)) { 152 | containerName = nodeValue.name; 153 | } else if (isObjectPattern(nodeValue)) { 154 | nodeValue.properties.forEach( 155 | (property) => 156 | isProperty(property) && 157 | ASTUtils.isIdentifier(property.key) && 158 | destructuredContainerPropNames.push(property.key.name) 159 | ); 160 | } 161 | } else if (ASTUtils.isIdentifier(node.id)) { 162 | renderResultVarName = node.id.name; 163 | } 164 | }, 165 | }; 166 | }, 167 | }); 168 | -------------------------------------------------------------------------------- /lib/rules/no-dom-import.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { isCallExpression, getImportModuleName } from '../node-utils'; 5 | 6 | export const RULE_NAME = 'no-dom-import'; 7 | export type MessageIds = 'noDomImport' | 'noDomImportFramework'; 8 | type Options = [string]; 9 | 10 | const DOM_TESTING_LIBRARY_MODULES = [ 11 | 'dom-testing-library', 12 | '@testing-library/dom', 13 | ]; 14 | 15 | const CORRECT_MODULE_NAME_BY_FRAMEWORK: Record< 16 | 'angular' | 'marko' | (string & NonNullable), 17 | string | undefined 18 | > = { 19 | angular: '@testing-library/angular', // ATL is *always* called `@testing-library/angular` 20 | marko: '@marko/testing-library', // Marko TL is called `@marko/testing-library` 21 | }; 22 | const getCorrectModuleName = ( 23 | moduleName: string, 24 | framework: string 25 | ): string => { 26 | return ( 27 | CORRECT_MODULE_NAME_BY_FRAMEWORK[framework] ?? 28 | moduleName.replace('dom', framework) 29 | ); 30 | }; 31 | 32 | export default createTestingLibraryRule({ 33 | name: RULE_NAME, 34 | meta: { 35 | type: 'problem', 36 | docs: { 37 | description: 'Disallow importing from DOM Testing Library', 38 | recommendedConfig: { 39 | dom: false, 40 | angular: ['error', 'angular'], 41 | react: ['error', 'react'], 42 | vue: ['error', 'vue'], 43 | svelte: ['error', 'svelte'], 44 | marko: ['error', 'marko'], 45 | }, 46 | }, 47 | messages: { 48 | noDomImport: 49 | 'import from DOM Testing Library is restricted, import from corresponding Testing Library framework instead', 50 | noDomImportFramework: 51 | 'import from DOM Testing Library is restricted, import from {{module}} instead', 52 | }, 53 | fixable: 'code', 54 | schema: [{ type: 'string' }], 55 | }, 56 | defaultOptions: [''], 57 | 58 | create(context, [framework], helpers) { 59 | function report( 60 | node: TSESTree.CallExpression | TSESTree.ImportDeclaration, 61 | moduleName: string 62 | ) { 63 | if (!framework) { 64 | return context.report({ 65 | node, 66 | messageId: 'noDomImport', 67 | }); 68 | } 69 | 70 | const correctModuleName = getCorrectModuleName(moduleName, framework); 71 | context.report({ 72 | data: { module: correctModuleName }, 73 | fix(fixer) { 74 | if (isCallExpression(node)) { 75 | const name = node.arguments[0] as TSESTree.Literal; 76 | 77 | // Replace the module name with the raw module name as we can't predict which punctuation the user is going to use 78 | return fixer.replaceText( 79 | name, 80 | name.raw.replace(moduleName, correctModuleName) 81 | ); 82 | } else { 83 | const name = node.source; 84 | return fixer.replaceText( 85 | name, 86 | name.raw.replace(moduleName, correctModuleName) 87 | ); 88 | } 89 | }, 90 | messageId: 'noDomImportFramework', 91 | node, 92 | }); 93 | } 94 | 95 | return { 96 | 'Program:exit'() { 97 | let importName: string | undefined; 98 | const allImportNodes = helpers.getAllTestingLibraryImportNodes(); 99 | 100 | allImportNodes.forEach((importNode) => { 101 | importName = getImportModuleName(importNode); 102 | 103 | const domModuleName = DOM_TESTING_LIBRARY_MODULES.find( 104 | (module) => module === importName 105 | ); 106 | 107 | if (!domModuleName) { 108 | return; 109 | } 110 | 111 | report(importNode, domModuleName); 112 | }); 113 | }, 114 | }; 115 | }, 116 | }); 117 | -------------------------------------------------------------------------------- /lib/rules/no-manual-cleanup.ts: -------------------------------------------------------------------------------- 1 | import { ASTUtils, TSESLint, TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { 5 | getImportModuleName, 6 | getVariableReferences, 7 | ImportModuleNode, 8 | isImportDeclaration, 9 | isImportDefaultSpecifier, 10 | isImportSpecifier, 11 | isMemberExpression, 12 | isObjectPattern, 13 | isProperty, 14 | } from '../node-utils'; 15 | import { getDeclaredVariables } from '../utils'; 16 | 17 | export const RULE_NAME = 'no-manual-cleanup'; 18 | export type MessageIds = 'noManualCleanup'; 19 | type Options = []; 20 | 21 | const CLEANUP_LIBRARY_REGEXP = 22 | /(@testing-library\/(preact|react|svelte|vue))|@marko\/testing-library/; 23 | 24 | export default createTestingLibraryRule({ 25 | name: RULE_NAME, 26 | meta: { 27 | type: 'problem', 28 | docs: { 29 | description: 'Disallow the use of `cleanup`', 30 | recommendedConfig: { 31 | dom: false, 32 | angular: false, 33 | react: 'error', 34 | vue: 'error', 35 | svelte: 'error', 36 | marko: false, 37 | }, 38 | }, 39 | messages: { 40 | noManualCleanup: 41 | "`cleanup` is performed automatically by your test runner, you don't need manual cleanups.", 42 | }, 43 | schema: [], 44 | }, 45 | defaultOptions: [], 46 | 47 | create(context, _, helpers) { 48 | function reportImportReferences(references: TSESLint.Scope.Reference[]) { 49 | for (const reference of references) { 50 | const utilsUsage = reference.identifier.parent; 51 | 52 | if ( 53 | utilsUsage && 54 | isMemberExpression(utilsUsage) && 55 | ASTUtils.isIdentifier(utilsUsage.property) && 56 | utilsUsage.property.name === 'cleanup' 57 | ) { 58 | context.report({ 59 | node: utilsUsage.property, 60 | messageId: 'noManualCleanup', 61 | }); 62 | } 63 | } 64 | } 65 | 66 | function reportCandidateModule(moduleNode: ImportModuleNode) { 67 | if (isImportDeclaration(moduleNode)) { 68 | // case: import utils from 'testing-library-module' 69 | if (isImportDefaultSpecifier(moduleNode.specifiers[0])) { 70 | const { references } = getDeclaredVariables(context, moduleNode)[0]; 71 | 72 | reportImportReferences(references); 73 | } 74 | 75 | // case: import { cleanup } from 'testing-library-module' 76 | const cleanupSpecifier = moduleNode.specifiers.find( 77 | (specifier) => 78 | isImportSpecifier(specifier) && 79 | ASTUtils.isIdentifier(specifier.imported) && 80 | specifier.imported.name === 'cleanup' 81 | ); 82 | 83 | if (cleanupSpecifier) { 84 | context.report({ 85 | node: cleanupSpecifier, 86 | messageId: 'noManualCleanup', 87 | }); 88 | } 89 | } else { 90 | const declaratorNode = moduleNode.parent as TSESTree.VariableDeclarator; 91 | 92 | if (isObjectPattern(declaratorNode.id)) { 93 | // case: const { cleanup } = require('testing-library-module') 94 | const cleanupProperty = declaratorNode.id.properties.find( 95 | (property) => 96 | isProperty(property) && 97 | ASTUtils.isIdentifier(property.key) && 98 | property.key.name === 'cleanup' 99 | ); 100 | 101 | if (cleanupProperty) { 102 | context.report({ 103 | node: cleanupProperty, 104 | messageId: 'noManualCleanup', 105 | }); 106 | } 107 | } else { 108 | // case: const utils = require('testing-library-module') 109 | const references = getVariableReferences(context, declaratorNode); 110 | reportImportReferences(references); 111 | } 112 | } 113 | } 114 | 115 | return { 116 | 'Program:exit'() { 117 | const customModuleImportNode = helpers.getCustomModuleImportNode(); 118 | 119 | for (const testingLibraryImportNode of helpers.getAllTestingLibraryImportNodes()) { 120 | const testingLibraryImportName = getImportModuleName( 121 | testingLibraryImportNode 122 | ); 123 | 124 | if (testingLibraryImportName?.match(CLEANUP_LIBRARY_REGEXP)) { 125 | reportCandidateModule(testingLibraryImportNode); 126 | } 127 | } 128 | 129 | if (customModuleImportNode) { 130 | reportCandidateModule(customModuleImportNode); 131 | } 132 | }, 133 | }; 134 | }, 135 | }); 136 | -------------------------------------------------------------------------------- /lib/rules/no-node-access.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree, ASTUtils } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { ALL_RETURNING_NODES } from '../utils'; 5 | 6 | export const RULE_NAME = 'no-node-access'; 7 | export type MessageIds = 'noNodeAccess'; 8 | export type Options = [{ allowContainerFirstChild: boolean }]; 9 | 10 | export default createTestingLibraryRule({ 11 | name: RULE_NAME, 12 | meta: { 13 | type: 'problem', 14 | docs: { 15 | description: 'Disallow direct Node access', 16 | recommendedConfig: { 17 | dom: 'error', 18 | angular: 'error', 19 | react: 'error', 20 | vue: 'error', 21 | svelte: 'error', 22 | marko: 'error', 23 | }, 24 | }, 25 | messages: { 26 | noNodeAccess: 27 | 'Avoid direct Node access. Prefer using the methods from Testing Library.', 28 | }, 29 | schema: [ 30 | { 31 | type: 'object', 32 | properties: { 33 | allowContainerFirstChild: { 34 | type: 'boolean', 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | defaultOptions: [ 41 | { 42 | allowContainerFirstChild: false, 43 | }, 44 | ], 45 | 46 | create(context, [{ allowContainerFirstChild = false }], helpers) { 47 | function showErrorForNodeAccess(node: TSESTree.MemberExpression) { 48 | // This rule is so aggressive that can cause tons of false positives outside test files when Aggressive Reporting 49 | // is enabled. Because of that, this rule will skip this mechanism and report only if some Testing Library package 50 | // or custom one (set in utils-module Shared Setting) is found. 51 | if (!helpers.isTestingLibraryImported(true)) { 52 | return; 53 | } 54 | 55 | const propertyName = ASTUtils.isIdentifier(node.property) 56 | ? node.property.name 57 | : null; 58 | 59 | if ( 60 | propertyName && 61 | ALL_RETURNING_NODES.some( 62 | (allReturningNode) => allReturningNode === propertyName 63 | ) 64 | ) { 65 | if (allowContainerFirstChild && propertyName === 'firstChild') { 66 | return; 67 | } 68 | 69 | if ( 70 | ASTUtils.isIdentifier(node.object) && 71 | node.object.name === 'props' 72 | ) { 73 | return; 74 | } 75 | 76 | context.report({ 77 | node, 78 | loc: node.property.loc.start, 79 | messageId: 'noNodeAccess', 80 | }); 81 | } 82 | } 83 | 84 | return { 85 | 'ExpressionStatement MemberExpression': showErrorForNodeAccess, 86 | 'VariableDeclarator MemberExpression': showErrorForNodeAccess, 87 | }; 88 | }, 89 | }); 90 | -------------------------------------------------------------------------------- /lib/rules/no-promise-in-fire-event.ts: -------------------------------------------------------------------------------- 1 | import { ASTUtils, TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { 5 | findClosestCallExpressionNode, 6 | getDeepestIdentifierNode, 7 | isCallExpression, 8 | isNewExpression, 9 | isPromiseIdentifier, 10 | } from '../node-utils'; 11 | import { getScope } from '../utils'; 12 | 13 | export const RULE_NAME = 'no-promise-in-fire-event'; 14 | export type MessageIds = 'noPromiseInFireEvent'; 15 | type Options = []; 16 | 17 | export default createTestingLibraryRule({ 18 | name: RULE_NAME, 19 | meta: { 20 | type: 'problem', 21 | docs: { 22 | description: 23 | 'Disallow the use of promises passed to a `fireEvent` method', 24 | recommendedConfig: { 25 | dom: 'error', 26 | angular: 'error', 27 | react: 'error', 28 | vue: 'error', 29 | svelte: 'error', 30 | marko: 'error', 31 | }, 32 | }, 33 | messages: { 34 | noPromiseInFireEvent: 35 | "A promise shouldn't be passed to a `fireEvent` method, instead pass the DOM element", 36 | }, 37 | schema: [], 38 | }, 39 | defaultOptions: [], 40 | 41 | create(context, _, helpers) { 42 | function checkSuspiciousNode( 43 | node: TSESTree.Node, 44 | originalNode?: TSESTree.Node 45 | ): void { 46 | if (ASTUtils.isAwaitExpression(node)) { 47 | return; 48 | } 49 | 50 | if (isNewExpression(node)) { 51 | if (isPromiseIdentifier(node.callee)) { 52 | context.report({ 53 | node: originalNode ?? node, 54 | messageId: 'noPromiseInFireEvent', 55 | }); 56 | return; 57 | } 58 | } 59 | 60 | if (isCallExpression(node)) { 61 | const domElementIdentifier = getDeepestIdentifierNode(node); 62 | 63 | if (!domElementIdentifier) { 64 | return; 65 | } 66 | 67 | if ( 68 | helpers.isAsyncQuery(domElementIdentifier) || 69 | isPromiseIdentifier(domElementIdentifier) 70 | ) { 71 | context.report({ 72 | node: originalNode ?? node, 73 | messageId: 'noPromiseInFireEvent', 74 | }); 75 | return; 76 | } 77 | } 78 | 79 | if (ASTUtils.isIdentifier(node)) { 80 | const nodeVariable = ASTUtils.findVariable( 81 | getScope(context, node), 82 | node.name 83 | ); 84 | if (!nodeVariable) { 85 | return; 86 | } 87 | 88 | for (const definition of nodeVariable.defs) { 89 | const variableDeclarator = 90 | definition.node as TSESTree.VariableDeclarator; 91 | if (variableDeclarator.init) { 92 | checkSuspiciousNode(variableDeclarator.init, node); 93 | } 94 | } 95 | } 96 | } 97 | 98 | return { 99 | 'CallExpression Identifier'(node: TSESTree.Identifier) { 100 | if (!helpers.isFireEventMethod(node)) { 101 | return; 102 | } 103 | 104 | const closestCallExpression = findClosestCallExpressionNode(node, true); 105 | 106 | if (!closestCallExpression) { 107 | return; 108 | } 109 | 110 | const domElementArgument = closestCallExpression.arguments[0]; 111 | 112 | checkSuspiciousNode(domElementArgument); 113 | }, 114 | }; 115 | }, 116 | }); 117 | -------------------------------------------------------------------------------- /lib/rules/no-render-in-lifecycle.ts: -------------------------------------------------------------------------------- 1 | import { ASTUtils, TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { 5 | getDeepestIdentifierNode, 6 | getFunctionName, 7 | getInnermostReturningFunction, 8 | isCallExpression, 9 | } from '../node-utils'; 10 | import { TESTING_FRAMEWORK_SETUP_HOOKS } from '../utils'; 11 | 12 | export const RULE_NAME = 'no-render-in-lifecycle'; 13 | export type MessageIds = 'noRenderInSetup'; 14 | type Options = [ 15 | { 16 | allowTestingFrameworkSetupHook?: string; 17 | }, 18 | ]; 19 | 20 | export function findClosestBeforeHook( 21 | node: TSESTree.Node | null, 22 | testingFrameworkSetupHooksToFilter: string[] 23 | ): TSESTree.Identifier | null { 24 | if (node === null) { 25 | return null; 26 | } 27 | 28 | if ( 29 | isCallExpression(node) && 30 | ASTUtils.isIdentifier(node.callee) && 31 | testingFrameworkSetupHooksToFilter.includes(node.callee.name) 32 | ) { 33 | return node.callee; 34 | } 35 | 36 | if (node.parent) { 37 | return findClosestBeforeHook( 38 | node.parent, 39 | testingFrameworkSetupHooksToFilter 40 | ); 41 | } 42 | 43 | return null; 44 | } 45 | 46 | export default createTestingLibraryRule({ 47 | name: RULE_NAME, 48 | meta: { 49 | type: 'problem', 50 | docs: { 51 | description: 52 | 'Disallow the use of `render` in testing frameworks setup functions', 53 | recommendedConfig: { 54 | dom: false, 55 | angular: 'error', 56 | react: 'error', 57 | vue: 'error', 58 | svelte: 'error', 59 | marko: 'error', 60 | }, 61 | }, 62 | messages: { 63 | noRenderInSetup: 64 | 'Forbidden usage of `render` within testing framework `{{ name }}` setup', 65 | }, 66 | schema: [ 67 | { 68 | type: 'object', 69 | properties: { 70 | allowTestingFrameworkSetupHook: { 71 | enum: [...TESTING_FRAMEWORK_SETUP_HOOKS], 72 | type: 'string', 73 | }, 74 | }, 75 | }, 76 | ], 77 | }, 78 | defaultOptions: [ 79 | { 80 | allowTestingFrameworkSetupHook: '', 81 | }, 82 | ], 83 | 84 | create(context, [{ allowTestingFrameworkSetupHook }], helpers) { 85 | const renderWrapperNames: string[] = []; 86 | 87 | function detectRenderWrapper(node: TSESTree.Identifier): void { 88 | const innerFunction = getInnermostReturningFunction(context, node); 89 | 90 | if (innerFunction) { 91 | renderWrapperNames.push(getFunctionName(innerFunction)); 92 | } 93 | } 94 | 95 | return { 96 | CallExpression(node) { 97 | const testingFrameworkSetupHooksToFilter = 98 | TESTING_FRAMEWORK_SETUP_HOOKS.filter( 99 | (hook) => hook !== allowTestingFrameworkSetupHook 100 | ); 101 | const callExpressionIdentifier = getDeepestIdentifierNode(node); 102 | 103 | if (!callExpressionIdentifier) { 104 | return; 105 | } 106 | 107 | const isRenderIdentifier = helpers.isRenderUtil( 108 | callExpressionIdentifier 109 | ); 110 | 111 | if (isRenderIdentifier) { 112 | detectRenderWrapper(callExpressionIdentifier); 113 | } 114 | 115 | if ( 116 | !isRenderIdentifier && 117 | !renderWrapperNames.includes(callExpressionIdentifier.name) 118 | ) { 119 | return; 120 | } 121 | 122 | const beforeHook = findClosestBeforeHook( 123 | node, 124 | testingFrameworkSetupHooksToFilter 125 | ); 126 | 127 | if (!beforeHook) { 128 | return; 129 | } 130 | 131 | context.report({ 132 | node: callExpressionIdentifier, 133 | messageId: 'noRenderInSetup', 134 | data: { 135 | name: beforeHook.name, 136 | }, 137 | }); 138 | }, 139 | }; 140 | }, 141 | }); 142 | -------------------------------------------------------------------------------- /lib/rules/no-test-id-queries.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { ALL_QUERIES_VARIANTS } from '../utils'; 5 | 6 | export const RULE_NAME = 'no-test-id-queries'; 7 | export type MessageIds = 'noTestIdQueries'; 8 | type Options = []; 9 | 10 | const QUERIES_REGEX = `/^(${ALL_QUERIES_VARIANTS.join('|')})TestId$/`; 11 | 12 | export default createTestingLibraryRule({ 13 | name: RULE_NAME, 14 | meta: { 15 | type: 'problem', 16 | docs: { 17 | description: 'Ensure no `data-testid` queries are used', 18 | recommendedConfig: { 19 | dom: false, 20 | angular: false, 21 | react: false, 22 | vue: false, 23 | svelte: false, 24 | marko: false, 25 | }, 26 | }, 27 | messages: { 28 | noTestIdQueries: 29 | 'Using `data-testid` queries is not recommended. Use a more descriptive query instead.', 30 | }, 31 | schema: [], 32 | }, 33 | defaultOptions: [], 34 | 35 | create(context) { 36 | return { 37 | [`CallExpression[callee.property.name=${QUERIES_REGEX}], CallExpression[callee.name=${QUERIES_REGEX}]`]( 38 | node: TSESTree.CallExpression 39 | ) { 40 | context.report({ 41 | node, 42 | messageId: 'noTestIdQueries', 43 | }); 44 | }, 45 | }; 46 | }, 47 | }); 48 | -------------------------------------------------------------------------------- /lib/rules/no-wait-for-multiple-assertions.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { getPropertyIdentifierNode } from '../node-utils'; 5 | 6 | export const RULE_NAME = 'no-wait-for-multiple-assertions'; 7 | export type MessageIds = 'noWaitForMultipleAssertion'; 8 | type Options = []; 9 | 10 | export default createTestingLibraryRule({ 11 | name: RULE_NAME, 12 | meta: { 13 | type: 'suggestion', 14 | docs: { 15 | description: 16 | 'Disallow the use of multiple `expect` calls inside `waitFor`', 17 | recommendedConfig: { 18 | dom: 'error', 19 | angular: 'error', 20 | react: 'error', 21 | vue: 'error', 22 | svelte: 'error', 23 | marko: 'error', 24 | }, 25 | }, 26 | messages: { 27 | noWaitForMultipleAssertion: 28 | 'Avoid using multiple assertions within `waitFor` callback', 29 | }, 30 | schema: [], 31 | }, 32 | defaultOptions: [], 33 | create(context, _, helpers) { 34 | function getExpectNodes( 35 | body: Array 36 | ): Array { 37 | return body.filter((node) => { 38 | const expressionIdentifier = getPropertyIdentifierNode(node); 39 | if (!expressionIdentifier) { 40 | return false; 41 | } 42 | 43 | return expressionIdentifier.name === 'expect'; 44 | }) as Array; 45 | } 46 | 47 | function reportMultipleAssertion(node: TSESTree.BlockStatement) { 48 | if (!node.parent) { 49 | return; 50 | } 51 | const callExpressionNode = node.parent.parent as TSESTree.CallExpression; 52 | const callExpressionIdentifier = 53 | getPropertyIdentifierNode(callExpressionNode); 54 | 55 | if (!callExpressionIdentifier) { 56 | return; 57 | } 58 | 59 | if (!helpers.isAsyncUtil(callExpressionIdentifier, ['waitFor'])) { 60 | return; 61 | } 62 | 63 | const expectNodes = getExpectNodes(node.body); 64 | 65 | if (expectNodes.length <= 1) { 66 | return; 67 | } 68 | 69 | for (let i = 0; i < expectNodes.length; i++) { 70 | if (i !== 0) { 71 | context.report({ 72 | node: expectNodes[i], 73 | messageId: 'noWaitForMultipleAssertion', 74 | }); 75 | } 76 | } 77 | } 78 | 79 | return { 80 | 'CallExpression > ArrowFunctionExpression > BlockStatement': 81 | reportMultipleAssertion, 82 | 'CallExpression > FunctionExpression > BlockStatement': 83 | reportMultipleAssertion, 84 | }; 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /lib/rules/no-wait-for-snapshot.ts: -------------------------------------------------------------------------------- 1 | import { ASTUtils, TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { 5 | findClosestCallExpressionNode, 6 | isMemberExpression, 7 | } from '../node-utils'; 8 | 9 | export const RULE_NAME = 'no-wait-for-snapshot'; 10 | export type MessageIds = 'noWaitForSnapshot'; 11 | type Options = []; 12 | 13 | const SNAPSHOT_REGEXP = /^(toMatchSnapshot|toMatchInlineSnapshot)$/; 14 | 15 | export default createTestingLibraryRule({ 16 | name: RULE_NAME, 17 | meta: { 18 | type: 'problem', 19 | docs: { 20 | description: 21 | 'Ensures no snapshot is generated inside of a `waitFor` call', 22 | recommendedConfig: { 23 | dom: 'error', 24 | angular: 'error', 25 | react: 'error', 26 | vue: 'error', 27 | svelte: 'error', 28 | marko: 'error', 29 | }, 30 | }, 31 | messages: { 32 | noWaitForSnapshot: 33 | "A snapshot can't be generated inside of a `{{ name }}` call", 34 | }, 35 | schema: [], 36 | }, 37 | defaultOptions: [], 38 | 39 | create(context, _, helpers) { 40 | function getClosestAsyncUtil( 41 | node: TSESTree.Node 42 | ): TSESTree.Identifier | null { 43 | let n: TSESTree.Node | null = node; 44 | do { 45 | const callExpression = findClosestCallExpressionNode(n); 46 | 47 | if (!callExpression) { 48 | return null; 49 | } 50 | 51 | if ( 52 | ASTUtils.isIdentifier(callExpression.callee) && 53 | helpers.isAsyncUtil(callExpression.callee) 54 | ) { 55 | return callExpression.callee; 56 | } 57 | if ( 58 | isMemberExpression(callExpression.callee) && 59 | ASTUtils.isIdentifier(callExpression.callee.property) && 60 | helpers.isAsyncUtil(callExpression.callee.property) 61 | ) { 62 | return callExpression.callee.property; 63 | } 64 | if (callExpression.parent) { 65 | n = findClosestCallExpressionNode(callExpression.parent); 66 | } 67 | } while (n !== null); 68 | return null; 69 | } 70 | 71 | return { 72 | [`Identifier[name=${String(SNAPSHOT_REGEXP)}]`]( 73 | node: TSESTree.Identifier 74 | ) { 75 | const closestAsyncUtil = getClosestAsyncUtil(node); 76 | if (closestAsyncUtil === null) { 77 | return; 78 | } 79 | context.report({ 80 | node, 81 | messageId: 'noWaitForSnapshot', 82 | data: { name: closestAsyncUtil.name }, 83 | }); 84 | }, 85 | }; 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /lib/rules/prefer-implicit-assert.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TSESTree, 3 | ASTUtils, 4 | AST_NODE_TYPES, 5 | TSESLint, 6 | } from '@typescript-eslint/utils'; 7 | 8 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 9 | import { TestingLibrarySettings } from '../create-testing-library-rule/detect-testing-library-utils'; 10 | import { isCallExpression, isMemberExpression } from '../node-utils'; 11 | 12 | export const RULE_NAME = 'prefer-implicit-assert'; 13 | export type MessageIds = 'preferImplicitAssert'; 14 | type Options = []; 15 | 16 | const isCalledUsingSomeObject = (node: TSESTree.Identifier) => 17 | isMemberExpression(node.parent) && 18 | node.parent.object.type === AST_NODE_TYPES.Identifier; 19 | 20 | const isCalledInExpect = ( 21 | node: TSESTree.Identifier | TSESTree.Node, 22 | isAsyncQuery: boolean 23 | ) => { 24 | if (isAsyncQuery) { 25 | return ( 26 | isCallExpression(node.parent) && 27 | ASTUtils.isAwaitExpression(node.parent.parent) && 28 | isCallExpression(node.parent.parent.parent) && 29 | ASTUtils.isIdentifier(node.parent.parent.parent.callee) && 30 | node.parent.parent.parent.callee.name === 'expect' 31 | ); 32 | } 33 | return ( 34 | isCallExpression(node.parent) && 35 | isCallExpression(node.parent.parent) && 36 | ASTUtils.isIdentifier(node.parent.parent.callee) && 37 | node.parent.parent.callee.name === 'expect' 38 | ); 39 | }; 40 | 41 | const reportError = ( 42 | context: Readonly< 43 | TSESLint.RuleContext<'preferImplicitAssert', []> & { 44 | settings: TestingLibrarySettings; 45 | } 46 | >, 47 | node: TSESTree.Identifier | TSESTree.Node | undefined, 48 | queryType: string 49 | ) => { 50 | if (node) { 51 | return context.report({ 52 | node, 53 | messageId: 'preferImplicitAssert', 54 | data: { 55 | queryType, 56 | }, 57 | }); 58 | } 59 | }; 60 | 61 | export default createTestingLibraryRule({ 62 | name: RULE_NAME, 63 | meta: { 64 | type: 'suggestion', 65 | docs: { 66 | description: 67 | 'Suggest using implicit assertions for getBy* & findBy* queries', 68 | recommendedConfig: { 69 | dom: false, 70 | angular: false, 71 | react: false, 72 | vue: false, 73 | svelte: false, 74 | marko: false, 75 | }, 76 | }, 77 | messages: { 78 | preferImplicitAssert: 79 | "Don't wrap `{{queryType}}` query with `expect` & presence matchers like `toBeInTheDocument` or `not.toBeNull` as `{{queryType}}` queries fail implicitly when element is not found", 80 | }, 81 | schema: [], 82 | }, 83 | defaultOptions: [], 84 | create(context, _, helpers) { 85 | const findQueryCalls: TSESTree.Identifier[] = []; 86 | const getQueryCalls: TSESTree.Identifier[] = []; 87 | 88 | return { 89 | 'CallExpression Identifier'(node: TSESTree.Identifier) { 90 | if (helpers.isFindQueryVariant(node)) { 91 | findQueryCalls.push(node); 92 | } 93 | if (helpers.isGetQueryVariant(node)) { 94 | getQueryCalls.push(node); 95 | } 96 | }, 97 | 'Program:exit'() { 98 | findQueryCalls.forEach((queryCall) => { 99 | const isAsyncQuery = true; 100 | const node: TSESTree.Identifier | TSESTree.Node | undefined = 101 | isCalledUsingSomeObject(queryCall) ? queryCall.parent : queryCall; 102 | 103 | if (node) { 104 | if (isCalledInExpect(node, isAsyncQuery)) { 105 | if ( 106 | isMemberExpression(node.parent?.parent?.parent?.parent) && 107 | node.parent?.parent?.parent?.parent.property.type === 108 | AST_NODE_TYPES.Identifier && 109 | helpers.isPresenceAssert(node.parent.parent.parent.parent) 110 | ) { 111 | return reportError(context, node, 'findBy*'); 112 | } 113 | } 114 | } 115 | }); 116 | 117 | getQueryCalls.forEach((queryCall) => { 118 | const isAsyncQuery = false; 119 | const node: TSESTree.Identifier | TSESTree.Node | undefined = 120 | isCalledUsingSomeObject(queryCall) ? queryCall.parent : queryCall; 121 | if (node) { 122 | if (isCalledInExpect(node, isAsyncQuery)) { 123 | if ( 124 | isMemberExpression(node.parent?.parent?.parent) && 125 | node.parent?.parent?.parent.property.type === 126 | AST_NODE_TYPES.Identifier && 127 | helpers.isPresenceAssert(node.parent.parent.parent) 128 | ) { 129 | return reportError(context, node, 'getBy*'); 130 | } 131 | } 132 | } 133 | }); 134 | }, 135 | }; 136 | }, 137 | }); 138 | -------------------------------------------------------------------------------- /lib/rules/prefer-presence-queries.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { findClosestCallNode, isMemberExpression } from '../node-utils'; 5 | 6 | export const RULE_NAME = 'prefer-presence-queries'; 7 | export type MessageIds = 'wrongAbsenceQuery' | 'wrongPresenceQuery'; 8 | export type Options = [ 9 | { 10 | presence?: boolean; 11 | absence?: boolean; 12 | }, 13 | ]; 14 | 15 | export default createTestingLibraryRule({ 16 | name: RULE_NAME, 17 | meta: { 18 | docs: { 19 | description: 20 | 'Ensure appropriate `get*`/`query*` queries are used with their respective matchers', 21 | recommendedConfig: { 22 | dom: 'error', 23 | angular: 'error', 24 | react: 'error', 25 | vue: 'error', 26 | svelte: 'error', 27 | marko: 'error', 28 | }, 29 | }, 30 | messages: { 31 | wrongPresenceQuery: 32 | 'Use `getBy*` queries rather than `queryBy*` for checking element is present', 33 | wrongAbsenceQuery: 34 | 'Use `queryBy*` queries rather than `getBy*` for checking element is NOT present', 35 | }, 36 | fixable: 'code', 37 | schema: [ 38 | { 39 | type: 'object', 40 | additionalProperties: false, 41 | properties: { 42 | presence: { 43 | type: 'boolean', 44 | }, 45 | absence: { 46 | type: 'boolean', 47 | }, 48 | }, 49 | }, 50 | ], 51 | type: 'suggestion', 52 | }, 53 | defaultOptions: [ 54 | { 55 | presence: true, 56 | absence: true, 57 | }, 58 | ], 59 | 60 | create(context, [{ absence = true, presence = true }], helpers) { 61 | return { 62 | 'CallExpression Identifier'(node: TSESTree.Identifier) { 63 | const expectCallNode = findClosestCallNode(node, 'expect'); 64 | const withinCallNode = findClosestCallNode(node, 'within'); 65 | 66 | if (!isMemberExpression(expectCallNode?.parent)) { 67 | return; 68 | } 69 | 70 | // Sync queries (getBy and queryBy) are corresponding ones used 71 | // to check presence or absence. If none found, stop the rule. 72 | if (!helpers.isSyncQuery(node)) { 73 | return; 74 | } 75 | 76 | const isPresenceQuery = helpers.isGetQueryVariant(node); 77 | const expectStatement = expectCallNode.parent; 78 | const isPresenceAssert = helpers.isPresenceAssert(expectStatement); 79 | const isAbsenceAssert = helpers.isAbsenceAssert(expectStatement); 80 | 81 | if (!isPresenceAssert && !isAbsenceAssert) { 82 | return; 83 | } 84 | 85 | if ( 86 | presence && 87 | (withinCallNode || isPresenceAssert) && 88 | !isPresenceQuery 89 | ) { 90 | const newQueryName = node.name.replace(/^query/, 'get'); 91 | 92 | context.report({ 93 | node, 94 | messageId: 'wrongPresenceQuery', 95 | fix: (fixer) => fixer.replaceText(node, newQueryName), 96 | }); 97 | } else if ( 98 | !withinCallNode && 99 | absence && 100 | isAbsenceAssert && 101 | isPresenceQuery 102 | ) { 103 | const newQueryName = node.name.replace(/^get/, 'query'); 104 | context.report({ 105 | node, 106 | messageId: 'wrongAbsenceQuery', 107 | fix: (fixer) => fixer.replaceText(node, newQueryName), 108 | }); 109 | } 110 | }, 111 | }; 112 | }, 113 | }); 114 | -------------------------------------------------------------------------------- /lib/rules/prefer-query-by-disappearance.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { 5 | getPropertyIdentifierNode, 6 | isArrowFunctionExpression, 7 | isCallExpression, 8 | isMemberExpression, 9 | isFunctionExpression, 10 | isExpressionStatement, 11 | isReturnStatement, 12 | isBlockStatement, 13 | } from '../node-utils'; 14 | 15 | export const RULE_NAME = 'prefer-query-by-disappearance'; 16 | type MessageIds = 'preferQueryByDisappearance'; 17 | type Options = []; 18 | 19 | export default createTestingLibraryRule({ 20 | name: RULE_NAME, 21 | meta: { 22 | type: 'problem', 23 | docs: { 24 | description: 25 | 'Suggest using `queryBy*` queries when waiting for disappearance', 26 | recommendedConfig: { 27 | dom: 'error', 28 | angular: 'error', 29 | react: 'error', 30 | vue: 'error', 31 | svelte: 'error', 32 | marko: 'error', 33 | }, 34 | }, 35 | messages: { 36 | preferQueryByDisappearance: 37 | 'Prefer using queryBy* when waiting for disappearance', 38 | }, 39 | schema: [], 40 | }, 41 | defaultOptions: [], 42 | 43 | create(context, _, helpers) { 44 | function isWaitForElementToBeRemoved(node: TSESTree.CallExpression) { 45 | const identifierNode = getPropertyIdentifierNode(node); 46 | 47 | if (!identifierNode) { 48 | return false; 49 | } 50 | 51 | return helpers.isAsyncUtil(identifierNode, ['waitForElementToBeRemoved']); 52 | } 53 | 54 | /** 55 | * Checks if node is reportable (starts with "get" or "find") and if it is, reports it with `context.report()`. 56 | * 57 | * @param {TSESTree.Expression} node - Node to be tested 58 | * @returns {Boolean} Boolean indicating if expression was reported 59 | */ 60 | function reportExpression(node: TSESTree.Expression): boolean { 61 | const argumentProperty = isMemberExpression(node) 62 | ? getPropertyIdentifierNode(node.property) 63 | : getPropertyIdentifierNode(node); 64 | 65 | if (!argumentProperty) { 66 | return false; 67 | } 68 | 69 | if ( 70 | helpers.isGetQueryVariant(argumentProperty) || 71 | helpers.isFindQueryVariant(argumentProperty) 72 | ) { 73 | context.report({ 74 | node: argumentProperty, 75 | messageId: 'preferQueryByDisappearance', 76 | }); 77 | return true; 78 | } 79 | return false; 80 | } 81 | 82 | function checkNonCallbackViolation(node: TSESTree.CallExpressionArgument) { 83 | if (!isCallExpression(node)) { 84 | return false; 85 | } 86 | 87 | if ( 88 | !isMemberExpression(node.callee) && 89 | !getPropertyIdentifierNode(node.callee) 90 | ) { 91 | return false; 92 | } 93 | 94 | return reportExpression(node.callee); 95 | } 96 | 97 | function isReturnViolation(node: TSESTree.Statement) { 98 | if (!isReturnStatement(node) || !isCallExpression(node.argument)) { 99 | return false; 100 | } 101 | 102 | return reportExpression(node.argument.callee); 103 | } 104 | 105 | function isNonReturnViolation(node: TSESTree.Statement) { 106 | if (!isExpressionStatement(node) || !isCallExpression(node.expression)) { 107 | return false; 108 | } 109 | 110 | if ( 111 | !isMemberExpression(node.expression.callee) && 112 | !getPropertyIdentifierNode(node.expression.callee) 113 | ) { 114 | return false; 115 | } 116 | 117 | return reportExpression(node.expression.callee); 118 | } 119 | 120 | function isStatementViolation(statement: TSESTree.Statement) { 121 | return isReturnViolation(statement) || isNonReturnViolation(statement); 122 | } 123 | 124 | function checkFunctionExpressionViolation( 125 | node: TSESTree.CallExpressionArgument 126 | ) { 127 | if (!isFunctionExpression(node)) { 128 | return false; 129 | } 130 | 131 | return node.body.body.some((statement) => 132 | isStatementViolation(statement) 133 | ); 134 | } 135 | 136 | function isArrowFunctionBodyViolation( 137 | node: TSESTree.CallExpressionArgument 138 | ) { 139 | if (!isArrowFunctionExpression(node) || !isBlockStatement(node.body)) { 140 | return false; 141 | } 142 | 143 | return node.body.body.some((statement) => 144 | isStatementViolation(statement) 145 | ); 146 | } 147 | 148 | function isArrowFunctionImplicitReturnViolation( 149 | node: TSESTree.CallExpressionArgument 150 | ) { 151 | if (!isArrowFunctionExpression(node) || !isCallExpression(node.body)) { 152 | return false; 153 | } 154 | 155 | if ( 156 | !isMemberExpression(node.body.callee) && 157 | !getPropertyIdentifierNode(node.body.callee) 158 | ) { 159 | return false; 160 | } 161 | 162 | return reportExpression(node.body.callee); 163 | } 164 | 165 | function checkArrowFunctionViolation( 166 | node: TSESTree.CallExpressionArgument 167 | ) { 168 | return ( 169 | isArrowFunctionBodyViolation(node) || 170 | isArrowFunctionImplicitReturnViolation(node) 171 | ); 172 | } 173 | 174 | function check(node: TSESTree.CallExpression) { 175 | if (!isWaitForElementToBeRemoved(node)) { 176 | return; 177 | } 178 | 179 | const argumentNode = node.arguments[0]; 180 | 181 | checkNonCallbackViolation(argumentNode); 182 | checkArrowFunctionViolation(argumentNode); 183 | checkFunctionExpressionViolation(argumentNode); 184 | } 185 | 186 | return { 187 | CallExpression: check, 188 | }; 189 | }, 190 | }); 191 | -------------------------------------------------------------------------------- /lib/rules/prefer-query-matchers.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { findClosestCallNode, isMemberExpression } from '../node-utils'; 5 | 6 | export const RULE_NAME = 'prefer-query-matchers'; 7 | export type MessageIds = 'wrongQueryForMatcher'; 8 | export type Options = [ 9 | { 10 | validEntries: { 11 | query: 'get' | 'query'; 12 | matcher: string; 13 | }[]; 14 | }, 15 | ]; 16 | 17 | export default createTestingLibraryRule({ 18 | name: RULE_NAME, 19 | meta: { 20 | docs: { 21 | description: 22 | 'Ensure the configured `get*`/`query*` query is used with the corresponding matchers', 23 | recommendedConfig: { 24 | dom: false, 25 | angular: false, 26 | react: false, 27 | vue: false, 28 | svelte: false, 29 | marko: false, 30 | }, 31 | }, 32 | messages: { 33 | wrongQueryForMatcher: 'Use `{{ query }}By*` queries for {{ matcher }}', 34 | }, 35 | schema: [ 36 | { 37 | type: 'object', 38 | additionalProperties: false, 39 | properties: { 40 | validEntries: { 41 | type: 'array', 42 | items: { 43 | type: 'object', 44 | properties: { 45 | query: { 46 | type: 'string', 47 | enum: ['get', 'query'], 48 | }, 49 | matcher: { 50 | type: 'string', 51 | }, 52 | }, 53 | }, 54 | }, 55 | }, 56 | }, 57 | ], 58 | type: 'suggestion', 59 | }, 60 | defaultOptions: [ 61 | { 62 | validEntries: [], 63 | }, 64 | ], 65 | 66 | create(context, [{ validEntries }], helpers) { 67 | return { 68 | 'CallExpression Identifier'(node: TSESTree.Identifier) { 69 | const expectCallNode = findClosestCallNode(node, 'expect'); 70 | 71 | if (!expectCallNode || !isMemberExpression(expectCallNode.parent)) { 72 | return; 73 | } 74 | 75 | // Sync queries (getBy and queryBy) and corresponding ones 76 | // are supported. If none found, stop the rule. 77 | if (!helpers.isSyncQuery(node)) { 78 | return; 79 | } 80 | 81 | const isGetBy = helpers.isGetQueryVariant(node); 82 | const expectStatement = expectCallNode.parent; 83 | for (const entry of validEntries) { 84 | const { query, matcher } = entry; 85 | const isMatchingAssertForThisEntry = helpers.isMatchingAssert( 86 | expectStatement, 87 | matcher 88 | ); 89 | 90 | if (!isMatchingAssertForThisEntry) { 91 | continue; 92 | } 93 | 94 | const actualQuery = isGetBy ? 'get' : 'query'; 95 | if (query !== actualQuery) { 96 | context.report({ 97 | node, 98 | messageId: 'wrongQueryForMatcher', 99 | data: { query, matcher }, 100 | }); 101 | } 102 | } 103 | }, 104 | }; 105 | }, 106 | }); 107 | -------------------------------------------------------------------------------- /lib/rules/render-result-naming-convention.ts: -------------------------------------------------------------------------------- 1 | import { ASTUtils, TSESTree } from '@typescript-eslint/utils'; 2 | 3 | import { createTestingLibraryRule } from '../create-testing-library-rule'; 4 | import { 5 | getDeepestIdentifierNode, 6 | getFunctionName, 7 | getInnermostReturningFunction, 8 | isObjectPattern, 9 | } from '../node-utils'; 10 | 11 | export const RULE_NAME = 'render-result-naming-convention'; 12 | export type MessageIds = 'renderResultNamingConvention'; 13 | 14 | type Options = []; 15 | 16 | const ALLOWED_VAR_NAMES = ['view', 'utils']; 17 | const ALLOWED_VAR_NAMES_TEXT = ALLOWED_VAR_NAMES.map((name) => `\`${name}\``) 18 | .join(', ') 19 | .replace(/, ([^,]*)$/, ', or $1'); 20 | 21 | export default createTestingLibraryRule({ 22 | name: RULE_NAME, 23 | meta: { 24 | type: 'suggestion', 25 | docs: { 26 | description: 'Enforce a valid naming for return value from `render`', 27 | recommendedConfig: { 28 | dom: false, 29 | angular: 'error', 30 | react: 'error', 31 | vue: 'error', 32 | svelte: 'error', 33 | marko: 'error', 34 | }, 35 | }, 36 | messages: { 37 | renderResultNamingConvention: `\`{{ renderResultName }}\` is not a recommended name for \`render\` returned value. Instead, you should destructure it, or name it using one of: ${ALLOWED_VAR_NAMES_TEXT}`, 38 | }, 39 | schema: [], 40 | }, 41 | defaultOptions: [], 42 | 43 | create(context, _, helpers) { 44 | const renderWrapperNames: string[] = []; 45 | 46 | function detectRenderWrapper(node: TSESTree.Identifier): void { 47 | const innerFunction = getInnermostReturningFunction(context, node); 48 | 49 | if (innerFunction) { 50 | renderWrapperNames.push(getFunctionName(innerFunction)); 51 | } 52 | } 53 | 54 | return { 55 | CallExpression(node) { 56 | const callExpressionIdentifier = getDeepestIdentifierNode(node); 57 | 58 | if (!callExpressionIdentifier) { 59 | return; 60 | } 61 | 62 | if (helpers.isRenderUtil(callExpressionIdentifier)) { 63 | detectRenderWrapper(callExpressionIdentifier); 64 | } 65 | }, 66 | VariableDeclarator(node) { 67 | if (!node.init) { 68 | return; 69 | } 70 | const initIdentifierNode = getDeepestIdentifierNode(node.init); 71 | 72 | if (!initIdentifierNode) { 73 | return; 74 | } 75 | 76 | if ( 77 | !helpers.isRenderVariableDeclarator(node) && 78 | !renderWrapperNames.includes(initIdentifierNode.name) 79 | ) { 80 | return; 81 | } 82 | 83 | // check if destructuring return value from render 84 | if (isObjectPattern(node.id)) { 85 | return; 86 | } 87 | 88 | const renderResultName = ASTUtils.isIdentifier(node.id) && node.id.name; 89 | 90 | if (!renderResultName) { 91 | return; 92 | } 93 | 94 | const isAllowedRenderResultName = 95 | ALLOWED_VAR_NAMES.includes(renderResultName); 96 | 97 | // check if return value var name is allowed 98 | if (isAllowedRenderResultName) { 99 | return; 100 | } 101 | 102 | context.report({ 103 | node, 104 | messageId: 'renderResultNamingConvention', 105 | data: { 106 | renderResultName, 107 | }, 108 | }); 109 | }, 110 | }; 111 | }, 112 | }); 113 | -------------------------------------------------------------------------------- /lib/utils/compat.ts: -------------------------------------------------------------------------------- 1 | import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; 2 | 3 | /* istanbul ignore next */ 4 | export const getFilename = ( 5 | context: TSESLint.RuleContext 6 | ) => { 7 | return context.filename ?? context.getFilename(); 8 | }; 9 | 10 | /* istanbul ignore next */ 11 | export const getSourceCode = ( 12 | context: TSESLint.RuleContext 13 | ) => { 14 | return context.sourceCode ?? context.getSourceCode(); 15 | }; 16 | 17 | /* istanbul ignore next */ 18 | export const getScope = ( 19 | context: TSESLint.RuleContext, 20 | node: TSESTree.Node 21 | ) => { 22 | return getSourceCode(context).getScope?.(node) ?? context.getScope(); 23 | }; 24 | 25 | /* istanbul ignore next */ 26 | export const getDeclaredVariables = ( 27 | context: TSESLint.RuleContext, 28 | node: TSESTree.Node 29 | ) => { 30 | return ( 31 | getSourceCode(context).getDeclaredVariables?.(node) ?? 32 | context.getDeclaredVariables(node) 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /lib/utils/file-import.ts: -------------------------------------------------------------------------------- 1 | // Copied from https://github.com/babel/babel/blob/b35c78f08dd854b08575fc66ebca323fdbc59dab/packages/babel-helpers/src/helpers.js#L615-L619 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | const interopRequireDefault = (obj: any): { default: T } => 4 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-return 5 | obj?.__esModule ? obj : { default: obj }; 6 | 7 | export const importDefault = (moduleName: string): T => 8 | // eslint-disable-next-line @typescript-eslint/no-require-imports 9 | interopRequireDefault(require(moduleName)).default; 10 | -------------------------------------------------------------------------------- /lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './compat'; 2 | export * from './file-import'; 3 | export * from './types'; 4 | 5 | const combineQueries = ( 6 | variants: readonly string[], 7 | methods: readonly string[] 8 | ): string[] => { 9 | const combinedQueries: string[] = []; 10 | variants.forEach((variant) => { 11 | const variantPrefix = variant.replace('By', ''); 12 | methods.forEach((method) => { 13 | combinedQueries.push(`${variantPrefix}${method}`); 14 | }); 15 | }); 16 | 17 | return combinedQueries; 18 | }; 19 | 20 | const getDocsUrl = (ruleName: string): string => 21 | `https://github.com/testing-library/eslint-plugin-testing-library/tree/main/docs/rules/${ruleName}.md`; 22 | 23 | const LIBRARY_MODULES = [ 24 | '@testing-library/dom', 25 | '@testing-library/angular', 26 | '@testing-library/react', 27 | '@testing-library/preact', 28 | '@testing-library/vue', 29 | '@testing-library/svelte', 30 | '@marko/testing-library', 31 | ] as const; 32 | 33 | const SYNC_QUERIES_VARIANTS = [ 34 | 'getBy', 35 | 'getAllBy', 36 | 'queryBy', 37 | 'queryAllBy', 38 | ] as const; 39 | const ASYNC_QUERIES_VARIANTS = ['findBy', 'findAllBy'] as const; 40 | const ALL_QUERIES_VARIANTS = [ 41 | ...SYNC_QUERIES_VARIANTS, 42 | ...ASYNC_QUERIES_VARIANTS, 43 | ] as const; 44 | 45 | const ALL_QUERIES_METHODS = [ 46 | 'ByLabelText', 47 | 'ByPlaceholderText', 48 | 'ByText', 49 | 'ByAltText', 50 | 'ByTitle', 51 | 'ByDisplayValue', 52 | 'ByRole', 53 | 'ByTestId', 54 | ] as const; 55 | 56 | const SYNC_QUERIES_COMBINATIONS = combineQueries( 57 | SYNC_QUERIES_VARIANTS, 58 | ALL_QUERIES_METHODS 59 | ); 60 | 61 | const ASYNC_QUERIES_COMBINATIONS = combineQueries( 62 | ASYNC_QUERIES_VARIANTS, 63 | ALL_QUERIES_METHODS 64 | ); 65 | 66 | const ALL_QUERIES_COMBINATIONS = [ 67 | ...SYNC_QUERIES_COMBINATIONS, 68 | ...ASYNC_QUERIES_COMBINATIONS, 69 | ] as const; 70 | 71 | const ASYNC_UTILS = ['waitFor', 'waitForElementToBeRemoved'] as const; 72 | 73 | const DEBUG_UTILS = [ 74 | 'debug', 75 | 'logTestingPlaygroundURL', 76 | 'prettyDOM', 77 | 'logRoles', 78 | 'logDOM', 79 | 'prettyFormat', 80 | ] as const; 81 | 82 | const EVENTS_SIMULATORS = ['fireEvent', 'userEvent'] as const; 83 | 84 | const TESTING_FRAMEWORK_SETUP_HOOKS = ['beforeEach', 'beforeAll'] as const; 85 | 86 | const PROPERTIES_RETURNING_NODES = [ 87 | 'activeElement', 88 | 'children', 89 | 'childElementCount', 90 | 'firstChild', 91 | 'firstElementChild', 92 | 'fullscreenElement', 93 | 'lastChild', 94 | 'lastElementChild', 95 | 'nextElementSibling', 96 | 'nextSibling', 97 | 'parentElement', 98 | 'parentNode', 99 | 'pointerLockElement', 100 | 'previousElementSibling', 101 | 'previousSibling', 102 | 'rootNode', 103 | 'scripts', 104 | ] as const; 105 | 106 | const METHODS_RETURNING_NODES = [ 107 | 'closest', 108 | 'getElementById', 109 | 'getElementsByClassName', 110 | 'getElementsByName', 111 | 'getElementsByTagName', 112 | 'getElementsByTagNameNS', 113 | 'querySelector', 114 | 'querySelectorAll', 115 | ] as const; 116 | 117 | const ALL_RETURNING_NODES = [ 118 | ...PROPERTIES_RETURNING_NODES, 119 | ...METHODS_RETURNING_NODES, 120 | ] as const; 121 | 122 | const PRESENCE_MATCHERS = [ 123 | 'toBeOnTheScreen', 124 | 'toBeInTheDocument', 125 | 'toBeTruthy', 126 | 'toBeDefined', 127 | ] as const; 128 | const ABSENCE_MATCHERS = ['toBeNull', 'toBeFalsy'] as const; 129 | 130 | export { 131 | combineQueries, 132 | getDocsUrl, 133 | SYNC_QUERIES_VARIANTS, 134 | ASYNC_QUERIES_VARIANTS, 135 | ALL_QUERIES_VARIANTS, 136 | ALL_QUERIES_METHODS, 137 | SYNC_QUERIES_COMBINATIONS, 138 | ASYNC_QUERIES_COMBINATIONS, 139 | ALL_QUERIES_COMBINATIONS, 140 | ASYNC_UTILS, 141 | DEBUG_UTILS, 142 | EVENTS_SIMULATORS, 143 | TESTING_FRAMEWORK_SETUP_HOOKS, 144 | LIBRARY_MODULES, 145 | PROPERTIES_RETURNING_NODES, 146 | METHODS_RETURNING_NODES, 147 | ALL_RETURNING_NODES, 148 | PRESENCE_MATCHERS, 149 | ABSENCE_MATCHERS, 150 | }; 151 | -------------------------------------------------------------------------------- /lib/utils/types.ts: -------------------------------------------------------------------------------- 1 | import { TSESLint } from '@typescript-eslint/utils'; 2 | 3 | type Recommended = 'error' | 'warn' | false; 4 | type RecommendedConfig = 5 | | Recommended 6 | | [Recommended, ...TOptions]; 7 | 8 | export type TestingLibraryPluginDocs = { 9 | /** 10 | * The recommendation level for the rule on a framework basis. 11 | * Used by the build tools to generate the framework config. 12 | * Set to `false` to not include it the config 13 | */ 14 | recommendedConfig: Record< 15 | SupportedTestingFramework, 16 | RecommendedConfig 17 | >; 18 | }; 19 | 20 | export type TestingLibraryPluginRuleModule< 21 | TMessageIds extends string, 22 | TOptions extends readonly unknown[], 23 | > = TSESLint.RuleModuleWithMetaDocs< 24 | TMessageIds, 25 | TOptions, 26 | TestingLibraryPluginDocs 27 | >; 28 | 29 | export const SUPPORTED_TESTING_FRAMEWORKS = [ 30 | 'dom', 31 | 'angular', 32 | 'react', 33 | 'vue', 34 | 'svelte', 35 | 'marko', 36 | ] as const; 37 | export type SupportedTestingFramework = 38 | (typeof SUPPORTED_TESTING_FRAMEWORKS)[number]; 39 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | //eslint-disable-next-line @typescript-eslint/no-require-imports 2 | const { ESLint } = require('eslint'); 3 | 4 | const removeIgnoredFiles = async (files) => { 5 | const eslint = new ESLint(); 6 | const ignoredFiles = await Promise.all( 7 | files.map((file) => eslint.isPathIgnored(file)) 8 | ); 9 | const filteredFiles = files.filter((_, i) => !ignoredFiles[i]); 10 | return filteredFiles.join(' '); 11 | }; 12 | 13 | module.exports = { 14 | '*.{js,ts}': async (files) => { 15 | const filesToLint = await removeIgnoredFiles(files); 16 | return [`eslint --max-warnings=0 ${filesToLint}`]; 17 | }, 18 | '*': 'prettier --write --ignore-unknown', 19 | }; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-testing-library", 3 | "version": "0.0.0-semantically-released", 4 | "description": "ESLint plugin to follow best practices and anticipate common mistakes when writing tests with Testing Library", 5 | "keywords": [ 6 | "eslint", 7 | "eslintplugin", 8 | "eslint-plugin", 9 | "lint", 10 | "testing-library", 11 | "testing" 12 | ], 13 | "homepage": "https://github.com/testing-library/eslint-plugin-testing-library", 14 | "bugs": { 15 | "url": "https://github.com/testing-library/eslint-plugin-testing-library/issues" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/testing-library/eslint-plugin-testing-library" 20 | }, 21 | "license": "MIT", 22 | "author": { 23 | "name": "Mario Beltrán Alarcón", 24 | "email": "me@mario.dev", 25 | "url": "https://mario.dev/" 26 | }, 27 | "files": [ 28 | "dist", 29 | "README.md", 30 | "LICENSE", 31 | "index.d.ts" 32 | ], 33 | "main": "./dist/index.js", 34 | "types": "index.d.ts", 35 | "scripts": { 36 | "prebuild": "del-cli dist", 37 | "build": "tsc -p ./tsconfig.build.json", 38 | "generate-all": "pnpm run --parallel \"/^generate:.*/\"", 39 | "generate-all:check": "pnpm run generate-all && git diff --exit-code", 40 | "generate:configs": "ts-node tools/generate-configs", 41 | "generate:rules-doc": "pnpm run build && pnpm run rule-doc-generator", 42 | "format": "pnpm run prettier-base --write", 43 | "format:check": "pnpm run prettier-base --check", 44 | "lint": "eslint . --max-warnings 0 --ext .js,.ts", 45 | "lint:fix": "pnpm run lint --fix", 46 | "prepare": "is-ci || husky", 47 | "prettier-base": "prettier . --ignore-unknown --cache --log-level warn", 48 | "rule-doc-generator": "eslint-doc-generator", 49 | "semantic-release": "semantic-release", 50 | "test": "jest", 51 | "test:ci": "pnpm run test --ci --coverage", 52 | "test:watch": "pnpm run test --watch", 53 | "type-check": "tsc --noEmit" 54 | }, 55 | "dependencies": { 56 | "@typescript-eslint/scope-manager": "^8.15.0", 57 | "@typescript-eslint/utils": "^8.15.0" 58 | }, 59 | "devDependencies": { 60 | "@commitlint/cli": "^19.6.0", 61 | "@commitlint/config-conventional": "^19.6.0", 62 | "@swc/core": "^1.9.3", 63 | "@swc/jest": "^0.2.37", 64 | "@types/jest": "^29.5.14", 65 | "@types/node": "^22.9.3", 66 | "@typescript-eslint/eslint-plugin": "^8.15.0", 67 | "@typescript-eslint/parser": "^8.15.0", 68 | "@typescript-eslint/rule-tester": "^8.15.0", 69 | "del-cli": "^6.0.0", 70 | "eslint": "^8.57.1", 71 | "eslint-config-prettier": "^9.1.0", 72 | "eslint-doc-generator": "^1.7.1", 73 | "eslint-import-resolver-typescript": "^3.6.3", 74 | "eslint-plugin-import": "^2.31.0", 75 | "eslint-plugin-jest": "^28.9.0", 76 | "eslint-plugin-jest-formatting": "^3.1.0", 77 | "eslint-plugin-node": "^11.1.0", 78 | "eslint-plugin-promise": "^7.1.0", 79 | "eslint-remote-tester": "^3.0.1", 80 | "eslint-remote-tester-repositories": "^1.0.1", 81 | "husky": "^9.1.7", 82 | "is-ci": "^3.0.1", 83 | "jest": "^29.7.0", 84 | "lint-staged": "^15.2.10", 85 | "prettier": "3.3.3", 86 | "semantic-release": "^24.2.0", 87 | "semver": "^7.6.3", 88 | "ts-node": "^10.9.2", 89 | "typescript": "^5.7.2" 90 | }, 91 | "peerDependencies": { 92 | "eslint": "^8.57.0 || ^9.0.0" 93 | }, 94 | "engines": { 95 | "node": "^18.18.0 || ^20.9.0 || >=21.1.0", 96 | "pnpm": "^9.14.0" 97 | }, 98 | "packageManager": "pnpm@9.14.2" 99 | } 100 | -------------------------------------------------------------------------------- /tests/eslint-remote-tester.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | const { rules } = require('eslint-plugin-testing-library'); 4 | const { 5 | getRepositories, 6 | getPathIgnorePattern, 7 | } = require('eslint-remote-tester-repositories'); 8 | 9 | module.exports = { 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 | plugins: ['testing-library'], 30 | rules: { 31 | ...Object.keys(rules).reduce( 32 | (all, rule) => ({ 33 | ...all, 34 | [`testing-library/${rule}`]: 'error', 35 | }), 36 | {} 37 | ), 38 | 39 | // Rules with required options without default values 40 | 'testing-library/consistent-data-testid': [ 41 | 'error', 42 | { testIdPattern: '^{fileName}(__([A-Z]+[a-z]_?)+)_$' }, 43 | ], 44 | }, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /tests/fake-rule.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Fake rule to be able to test createTestingLibraryRule and 3 | * detectTestingLibraryUtils properly 4 | */ 5 | import { TSESTree } from '@typescript-eslint/utils'; 6 | 7 | import { createTestingLibraryRule } from '../lib/create-testing-library-rule'; 8 | 9 | export const RULE_NAME = 'fake-rule'; 10 | type Options = []; 11 | type MessageIds = 12 | | 'absenceAssertError' 13 | | 'asyncUtilError' 14 | | 'customQueryError' 15 | | 'fakeError' 16 | | 'findByError' 17 | | 'getByError' 18 | | 'presenceAssertError' 19 | | 'queryByError' 20 | | 'renderError' 21 | | 'userEventError'; 22 | 23 | export default createTestingLibraryRule({ 24 | name: RULE_NAME, 25 | meta: { 26 | type: 'problem', 27 | docs: { 28 | description: 'Fake rule to test rule maker and detection helpers', 29 | recommendedConfig: { 30 | dom: false, 31 | angular: false, 32 | react: false, 33 | vue: false, 34 | svelte: false, 35 | marko: false, 36 | }, 37 | }, 38 | messages: { 39 | fakeError: 'fake error reported', 40 | renderError: 'some error related to render util reported', 41 | asyncUtilError: 42 | 'some error related to {{ utilName }} async util reported', 43 | getByError: 'some error related to getBy reported', 44 | queryByError: 'some error related to queryBy reported', 45 | findByError: 'some error related to findBy reported', 46 | customQueryError: 'some error related to a customQuery reported', 47 | userEventError: 'some error related to userEvent reported', 48 | presenceAssertError: 'some error related to presence assert reported', 49 | absenceAssertError: 'some error related to absence assert reported', 50 | }, 51 | schema: [], 52 | }, 53 | defaultOptions: [], 54 | create(context, _, helpers) { 55 | const reportCallExpressionIdentifier = (node: TSESTree.Identifier) => { 56 | // force "render" to be reported 57 | if (helpers.isRenderUtil(node)) { 58 | return context.report({ node, messageId: 'renderError' }); 59 | } 60 | 61 | // force async utils to be reported 62 | if (helpers.isAsyncUtil(node)) { 63 | return context.report({ 64 | node, 65 | messageId: 'asyncUtilError', 66 | data: { utilName: node.name }, 67 | }); 68 | } 69 | 70 | if (helpers.isUserEventMethod(node)) { 71 | return context.report({ node, messageId: 'userEventError' }); 72 | } 73 | 74 | // force queries to be reported 75 | if (helpers.isCustomQuery(node)) { 76 | return context.report({ node, messageId: 'customQueryError' }); 77 | } 78 | 79 | if (helpers.isGetQueryVariant(node)) { 80 | return context.report({ node, messageId: 'getByError' }); 81 | } 82 | 83 | if (helpers.isQueryQueryVariant(node)) { 84 | return context.report({ node, messageId: 'queryByError' }); 85 | } 86 | 87 | if (helpers.isFindQueryVariant(node)) { 88 | return context.report({ node, messageId: 'findByError' }); 89 | } 90 | 91 | return undefined; 92 | }; 93 | 94 | const reportMemberExpression = (node: TSESTree.MemberExpression) => { 95 | if (helpers.isPresenceAssert(node)) { 96 | return context.report({ node, messageId: 'presenceAssertError' }); 97 | } 98 | 99 | if (helpers.isAbsenceAssert(node)) { 100 | return context.report({ node, messageId: 'absenceAssertError' }); 101 | } 102 | 103 | return undefined; 104 | }; 105 | 106 | const reportImportDeclaration = (node: TSESTree.ImportDeclaration) => { 107 | // This is just to check that defining an `ImportDeclaration` doesn't 108 | // override `ImportDeclaration` from `detectTestingLibraryUtils` 109 | if (node.source.value === 'report-me') { 110 | context.report({ node, messageId: 'fakeError' }); 111 | } 112 | }; 113 | 114 | return { 115 | 'CallExpression Identifier': reportCallExpressionIdentifier, 116 | MemberExpression: reportMemberExpression, 117 | ImportDeclaration: reportImportDeclaration, 118 | 'Program:exit'() { 119 | const importNode = helpers.getCustomModuleImportNode(); 120 | const importName = helpers.getCustomModuleImportName(); 121 | if (!importNode) { 122 | return; 123 | } 124 | 125 | if (importName === 'custom-module-forced-report') { 126 | context.report({ 127 | node: importNode, 128 | messageId: 'fakeError', 129 | }); 130 | } 131 | }, 132 | }; 133 | }, 134 | }); 135 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs'; 2 | import { resolve } from 'path'; 3 | 4 | import plugin from '../lib'; 5 | 6 | const numberOfRules = 28; 7 | const ruleNames = Object.keys(plugin.rules); 8 | 9 | // eslint-disable-next-line jest/expect-expect 10 | it('should have a corresponding doc for each rule', () => { 11 | ruleNames.forEach((rule) => { 12 | const docPath = resolve(__dirname, '../docs/rules', `${rule}.md`); 13 | 14 | if (!existsSync(docPath)) { 15 | throw new Error( 16 | `Could not find documentation file for rule "${rule}" in path "${docPath}"` 17 | ); 18 | } 19 | }); 20 | }); 21 | 22 | // eslint-disable-next-line jest/expect-expect 23 | it('should have a corresponding test for each rule', () => { 24 | ruleNames.forEach((rule) => { 25 | const testPath = resolve(__dirname, './lib/rules/', `${rule}.test.ts`); 26 | 27 | if (!existsSync(testPath)) { 28 | throw new Error( 29 | `Could not find test file for rule "${rule}" in path "${testPath}"` 30 | ); 31 | } 32 | }); 33 | }); 34 | 35 | // eslint-disable-next-line jest/expect-expect 36 | it('should have the correct amount of rules', () => { 37 | const { length } = ruleNames; 38 | 39 | if (length !== numberOfRules) { 40 | throw new Error( 41 | `There should be exactly ${numberOfRules} rules, but there are ${length}. If you've added a new rule, please update this number.` 42 | ); 43 | } 44 | }); 45 | 46 | it('should export configs that refer to actual rules', () => { 47 | const allConfigs = plugin.configs; 48 | 49 | expect(Object.keys(allConfigs)).toEqual([ 50 | 'dom', 51 | 'angular', 52 | 'react', 53 | 'vue', 54 | 'svelte', 55 | 'marko', 56 | 'flat/dom', 57 | 'flat/angular', 58 | 'flat/react', 59 | 'flat/vue', 60 | 'flat/svelte', 61 | 'flat/marko', 62 | ]); 63 | const allConfigRules = Object.values(allConfigs) 64 | .map((config) => Object.keys(config.rules ?? {})) 65 | .reduce((previousValue, currentValue) => [ 66 | ...previousValue, 67 | ...currentValue, 68 | ]); 69 | 70 | allConfigRules.forEach((rule) => { 71 | const ruleNamePrefix = 'testing-library/'; 72 | const ruleName = rule.slice(ruleNamePrefix.length); 73 | 74 | expect(rule.startsWith(ruleNamePrefix)).toBe(true); 75 | expect(ruleNames).toContain(ruleName); 76 | 77 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-require-imports 78 | expect(() => require(`../lib/rules/${ruleName}`)).not.toThrow(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /tests/lib/rules/no-promise-in-fire-event.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../../../lib/rules/no-promise-in-fire-event'; 2 | import { createRuleTester } from '../test-utils'; 3 | 4 | const ruleTester = createRuleTester(); 5 | 6 | const SUPPORTED_TESTING_FRAMEWORKS = [ 7 | '@testing-library/dom', 8 | '@testing-library/angular', 9 | '@testing-library/react', 10 | '@testing-library/vue', 11 | '@marko/testing-library', 12 | ]; 13 | 14 | ruleTester.run(RULE_NAME, rule, { 15 | valid: [ 16 | ...SUPPORTED_TESTING_FRAMEWORKS.flatMap((testingFramework) => [ 17 | { 18 | code: ` 19 | import {fireEvent} from '${testingFramework}'; 20 | 21 | fireEvent.click(screen.getByRole('button')) 22 | `, 23 | }, 24 | { 25 | code: ` 26 | import {fireEvent} from '${testingFramework}'; 27 | 28 | fireEvent.click(queryByRole('button')) 29 | `, 30 | }, 31 | { 32 | code: ` 33 | import {fireEvent} from '${testingFramework}'; 34 | 35 | fireEvent.click(someRef) 36 | `, 37 | }, 38 | { 39 | code: ` 40 | import {fireEvent} from '${testingFramework}'; 41 | 42 | fireEvent.click(await screen.findByRole('button')) 43 | `, 44 | }, 45 | { 46 | code: ` 47 | import {fireEvent} from '${testingFramework}' 48 | 49 | const elementPromise = screen.findByRole('button') 50 | const button = await elementPromise 51 | fireEvent.click(button) 52 | `, 53 | }, 54 | ]), 55 | { 56 | settings: { 57 | 'testing-library/utils-module': 'test-utils', 58 | }, 59 | code: `// invalid usage but aggressive reporting opted-out 60 | import { fireEvent } from 'somewhere-else' 61 | fireEvent.click(findByText('submit')) 62 | `, 63 | }, 64 | `// edge case for coverage: 65 | // valid use case without call expression 66 | // so there is no innermost function scope found 67 | test('edge case for no innermost function scope', () => { 68 | const click = fireEvent.click 69 | }) 70 | `, 71 | `// edge case for coverage: 72 | // new expression of something else than Promise 73 | fireEvent.click(new SomeElement()) 74 | `, 75 | ], 76 | invalid: [ 77 | { 78 | // aggressive reporting opted-in 79 | code: `fireEvent.click(findByText('submit'))`, 80 | errors: [ 81 | { 82 | messageId: 'noPromiseInFireEvent', 83 | line: 1, 84 | column: 17, 85 | endColumn: 37, 86 | }, 87 | ], 88 | }, 89 | { 90 | // aggressive reporting opted-in 91 | code: `fireEvent.click(Promise())`, 92 | errors: [ 93 | { 94 | messageId: 'noPromiseInFireEvent', 95 | line: 1, 96 | column: 17, 97 | endColumn: 26, 98 | }, 99 | ], 100 | }, 101 | ...SUPPORTED_TESTING_FRAMEWORKS.flatMap((testingFramework) => [ 102 | { 103 | code: ` 104 | import {fireEvent} from '${testingFramework}'; 105 | 106 | const promise = new Promise(); 107 | fireEvent.click(promise)`, 108 | errors: [ 109 | { 110 | messageId: 'noPromiseInFireEvent', 111 | line: 5, 112 | column: 25, 113 | endColumn: 32, 114 | }, 115 | ], 116 | } as const, 117 | { 118 | code: ` 119 | import {fireEvent} from '${testingFramework}' 120 | 121 | const elementPromise = screen.findByRole('button') 122 | fireEvent.click(elementPromise)`, 123 | errors: [ 124 | { 125 | messageId: 'noPromiseInFireEvent', 126 | line: 5, 127 | column: 25, 128 | endColumn: 39, 129 | }, 130 | ], 131 | } as const, 132 | { 133 | code: ` 134 | import {fireEvent} from '${testingFramework}'; 135 | 136 | fireEvent.click(screen.findByRole('button'))`, 137 | errors: [ 138 | { 139 | messageId: 'noPromiseInFireEvent', 140 | line: 4, 141 | column: 25, 142 | endColumn: 52, 143 | }, 144 | ], 145 | } as const, 146 | { 147 | code: ` 148 | import {fireEvent} from '${testingFramework}'; 149 | 150 | fireEvent.click(findByText('submit'))`, 151 | errors: [ 152 | { 153 | messageId: 'noPromiseInFireEvent', 154 | line: 4, 155 | column: 25, 156 | endColumn: 45, 157 | }, 158 | ], 159 | } as const, 160 | { 161 | code: ` 162 | import {fireEvent} from '${testingFramework}'; 163 | 164 | fireEvent.click(Promise('foo'))`, 165 | errors: [ 166 | { 167 | messageId: 'noPromiseInFireEvent', 168 | line: 4, 169 | column: 25, 170 | endColumn: 39, 171 | }, 172 | ], 173 | } as const, 174 | { 175 | code: ` 176 | import {fireEvent} from '${testingFramework}'; 177 | 178 | fireEvent.click(new Promise('foo'))`, 179 | errors: [ 180 | { 181 | messageId: 'noPromiseInFireEvent', 182 | line: 4, 183 | column: 25, 184 | endColumn: 43, 185 | }, 186 | ], 187 | } as const, 188 | ]), 189 | ], 190 | }); 191 | -------------------------------------------------------------------------------- /tests/lib/rules/no-test-id-queries.test.ts: -------------------------------------------------------------------------------- 1 | import rule, { RULE_NAME } from '../../../lib/rules/no-test-id-queries'; 2 | import { createRuleTester } from '../test-utils'; 3 | 4 | const ruleTester = createRuleTester(); 5 | 6 | const SUPPORTED_TESTING_FRAMEWORKS = [ 7 | '@testing-library/dom', 8 | '@testing-library/angular', 9 | '@testing-library/react', 10 | '@testing-library/vue', 11 | '@marko/testing-library', 12 | ]; 13 | 14 | const QUERIES = [ 15 | 'getByTestId', 16 | 'queryByTestId', 17 | 'getAllByTestId', 18 | 'queryAllByTestId', 19 | 'findByTestId', 20 | 'findAllByTestId', 21 | ]; 22 | 23 | ruleTester.run(RULE_NAME, rule, { 24 | valid: [ 25 | ` 26 | import { render } from '@testing-library/react'; 27 | 28 | test('test', async () => { 29 | const { getByRole } = render(); 30 | 31 | expect(getByRole('button')).toBeInTheDocument(); 32 | }); 33 | `, 34 | 35 | ` 36 | import { render } from '@testing-library/react'; 37 | 38 | test('test', async () => { 39 | render(); 40 | 41 | expect(getTestId('button')).toBeInTheDocument(); 42 | }); 43 | `, 44 | ], 45 | 46 | invalid: SUPPORTED_TESTING_FRAMEWORKS.flatMap((framework) => 47 | QUERIES.flatMap((query) => [ 48 | { 49 | code: ` 50 | import { render } from '${framework}'; 51 | 52 | test('test', async () => { 53 | const { ${query} } = render(); 54 | 55 | expect(${query}('my-test-id')).toBeInTheDocument(); 56 | }); 57 | `, 58 | errors: [ 59 | { 60 | messageId: 'noTestIdQueries', 61 | line: 7, 62 | column: 14, 63 | }, 64 | ], 65 | }, 66 | { 67 | code: ` 68 | import { render, screen } from '${framework}'; 69 | 70 | test('test', async () => { 71 | render(); 72 | 73 | expect(screen.${query}('my-test-id')).toBeInTheDocument(); 74 | }); 75 | `, 76 | errors: [ 77 | { 78 | messageId: 'noTestIdQueries', 79 | line: 7, 80 | column: 14, 81 | }, 82 | ], 83 | }, 84 | ]) 85 | ), 86 | }); 87 | -------------------------------------------------------------------------------- /tests/lib/test-utils.ts: -------------------------------------------------------------------------------- 1 | import tsESLintParser from '@typescript-eslint/parser'; 2 | import { RuleTester, RunTests } from '@typescript-eslint/rule-tester'; 3 | 4 | import { TestingLibraryPluginRuleModule } from '../../lib/utils'; 5 | 6 | const DEFAULT_TEST_CASE_CONFIG = { 7 | filename: 'MyComponent.test.js', 8 | }; 9 | 10 | class TestingLibraryRuleTester extends RuleTester { 11 | run( 12 | ruleName: string, 13 | rule: TestingLibraryPluginRuleModule, 14 | { invalid, valid }: RunTests 15 | ): void { 16 | const finalValid = valid.map((testCase) => { 17 | if (typeof testCase === 'string') { 18 | return { 19 | ...DEFAULT_TEST_CASE_CONFIG, 20 | code: testCase, 21 | }; 22 | } 23 | 24 | return { ...DEFAULT_TEST_CASE_CONFIG, ...testCase }; 25 | }); 26 | const finalInvalid = invalid.map((testCase) => ({ 27 | ...DEFAULT_TEST_CASE_CONFIG, 28 | ...testCase, 29 | })); 30 | 31 | super.run(ruleName, rule, { valid: finalValid, invalid: finalInvalid }); 32 | } 33 | } 34 | 35 | export const createRuleTester = () => 36 | new TestingLibraryRuleTester({ 37 | languageOptions: { 38 | parser: tsESLintParser, 39 | parserOptions: { 40 | ecmaFeatures: { 41 | jsx: true, 42 | }, 43 | }, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /tools/generate-configs/index.ts: -------------------------------------------------------------------------------- 1 | import { type TSESLint } from '@typescript-eslint/utils'; 2 | 3 | import rules from '../../lib/rules'; 4 | import { 5 | SUPPORTED_TESTING_FRAMEWORKS, 6 | SupportedTestingFramework, 7 | TestingLibraryPluginRuleModule, 8 | } from '../../lib/utils'; 9 | 10 | import { writeConfig } from './utils'; 11 | 12 | const RULE_NAME_PREFIX = 'testing-library/'; 13 | 14 | const getRecommendedRulesForTestingFramework = ( 15 | framework: SupportedTestingFramework 16 | ): Record => 17 | Object.entries>(rules) 18 | .filter(([_, { meta }]) => Boolean(meta.docs.recommendedConfig[framework])) 19 | .reduce((allRules, [ruleName, { meta }]) => { 20 | const name = `${RULE_NAME_PREFIX}${ruleName}`; 21 | const recommendation = meta.docs.recommendedConfig[framework]; 22 | 23 | return { 24 | ...allRules, 25 | [name]: recommendation, 26 | }; 27 | }, {}); 28 | 29 | (async () => { 30 | for (const framework of SUPPORTED_TESTING_FRAMEWORKS) { 31 | const specificFrameworkConfig: TSESLint.Linter.ConfigType = { 32 | plugins: ['testing-library'], 33 | rules: getRecommendedRulesForTestingFramework(framework), 34 | }; 35 | 36 | await writeConfig(specificFrameworkConfig, framework); 37 | } 38 | })().catch((error) => { 39 | console.error(error); 40 | process.exitCode = 1; 41 | }); 42 | -------------------------------------------------------------------------------- /tools/generate-configs/utils.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from 'fs/promises'; 2 | import { resolve } from 'path'; 3 | 4 | import { type TSESLint } from '@typescript-eslint/utils'; 5 | import { format, resolveConfig } from 'prettier'; 6 | 7 | const prettierConfig = resolveConfig(__dirname); 8 | 9 | const addAutoGeneratedComment = (code: string) => 10 | [ 11 | '// THIS CODE WAS AUTOMATICALLY GENERATED', 12 | '// DO NOT EDIT THIS CODE BY HAND', 13 | '// YOU CAN REGENERATE IT USING pnpm run generate:configs', 14 | '', 15 | code, 16 | ].join('\n'); 17 | 18 | /** 19 | * Helper function writes configuration. 20 | */ 21 | export const writeConfig = async ( 22 | config: TSESLint.Linter.ConfigType, 23 | configName: string 24 | ): Promise => { 25 | // note: we use `export =` because ESLint will import these configs via a commonjs import 26 | const code = `export = ${JSON.stringify(config)};`; 27 | const configStr = await format(addAutoGeneratedComment(code), { 28 | parser: 'typescript', 29 | ...(await prettierConfig), 30 | }); 31 | const filePath = resolve(__dirname, `../../lib/configs/${configName}.ts`); 32 | 33 | await writeFile(filePath, configStr); 34 | }; 35 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["./tests/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["**/*.ts", "**/*.js"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ES2019", 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "lib": ["ES2019"], 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "resolveJsonModule": true, 11 | "moduleDetection": "force", 12 | "isolatedModules": true, 13 | "removeComments": true, 14 | // TODO: turn it on 15 | "noUncheckedIndexedAccess": false, 16 | "outDir": "dist", 17 | "sourceMap": false 18 | }, 19 | "include": ["./lib/**/*.ts", "./tests/**/*.ts"] 20 | } 21 | --------------------------------------------------------------------------------