├── .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 |
--------------------------------------------------------------------------------