diff --git a/.eslintignore b/.eslintignore index 404abb22..d64c4ca2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ coverage/ +dist/ diff --git a/.eslintrc.json b/.eslintrc.json index 5195afc4..f04ce17a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -8,7 +8,7 @@ "extends": [ "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", - "prettier/@typescript-eslint", + "prettier", "plugin:jest/recommended", "plugin:jest-formatting/recommended" ], diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c54be545..bb85b6f9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,11 @@ version: 2 updates: - # Set update schedule for GitHub Actions - - package-ecosystem: 'github-actions' - directory: '/' + - package-ecosystem: github-actions + directory: / schedule: - interval: 'daily' + interval: daily + + - package-ecosystem: npm + directory: / + schedule: + interval: daily diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 3bf7a6a3..56c3a9c8 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -24,6 +24,7 @@ jobs: uses: styfle/cancel-workflow-action@0.8.0 with: access_token: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout uses: actions/checkout@v2 @@ -37,12 +38,14 @@ jobs: with: useLockFile: false + - name: Check Types + run: npm run type-check + - name: Lint code run: npm run lint - # TODO: reenable on v4 + run tsc - # - name: Check format - # run: npm run format:check + - name: Check format + run: npm run format:check tests: name: Tests (Node v${{ matrix.node }} - ESLint v${{ matrix.eslint }}) @@ -51,7 +54,7 @@ jobs: strategy: matrix: node: [ '10.12', '10', '12.0', '12', '14' ] - eslint: [ '5', '6', '7' ] + eslint: [ '7.5', '7' ] steps: - name: Cancel Previous Runs diff --git a/.huskyrc b/.huskyrc index 4b0568aa..914cf42e 100644 --- a/.huskyrc +++ b/.huskyrc @@ -1,6 +1,6 @@ { "hooks": { - "pre-commit": "npm run test && lint-staged", + "pre-commit": "lint-staged", "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" } } diff --git a/.lintstagedrc b/.lintstagedrc index a9a715c0..b8a24a6f 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,4 +1,8 @@ { - "*.js": ["eslint --fix", "git add"], - "*.md": ["prettier --write", "git add"] + "*.{js,ts}": [ + "eslint --max-warnings 0 --fix", + "prettier --write", + "jest --findRelatedTests" + ], + "*.md": ["prettier --write"] } diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..43c97e71 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 00000000..e340799c --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + singleQuote: true, +}; diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index c6a1376d..00000000 --- a/.prettierrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "trailingComma": "es5", - "singleQuote": true -} diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 00000000..dd5a8466 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,17 @@ +{ + "pkgRoot": "dist", + "branches": [ + "+([0-9])?(.{+([0-9]),x}).x", + "main", + "next", + "next-major", + { + "name": "beta", + "prerelease": true + }, + { + "name": "alpha", + "prerelease": true + } + ] +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6249b2d8..ca41f9e2 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -14,22 +14,22 @@ appearance, race, religion, or sexual identity and orientation. Examples of behavior that contributes to creating a positive environment include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting ## Our Responsibilities diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8600c62e..ca86b31a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,41 @@ Additionally, you need to do a couple of extra things: - Include your rule in the "Supported Rules" table within the [README.md](./README.md). Don't forget to include the proper badges if needed and to sort alphabetically the rules for readability. -## Modifying rules +### Custom rule creator + +In v4 we introduced several improvements for both the final users as for contributors. Now there is a custom Rule Creator available called `createTestingLibraryRule` which should be the default Rule Creator used in this plugin. This Testing Library Rule Creator extends TSESLint Rule Creator to enhance rules automatically, so it prevents rules to be reported if nothing related to Testing Library found, and injects a 3rd parameter within `create` function: `helpers`. + +This new `helpers` available in the `create` of the rule gives you access to a bunch of utils to detect things related to Testing Library. You can find all of them in `detect-testing-library-utils.ts` file, but these are some helpers available: + +- `isTestingLibraryImported` +- `isGetQueryVariant` +- `isQueryQueryVariant` +- `isFindQueryVariant` +- `isSyncQuery` +- `isAsyncQuery` +- `isQuery` +- `isCustomQuery` +- `isAsyncUtil` +- `isFireEventUtil` +- `isUserEventUtil` +- `isFireEventMethod` +- `isUserEventMethod` +- `isRenderUtil` +- `isRenderVariableDeclarator` +- `isDebugUtil` +- `isPresenceAssert` +- `isAbsenceAssert` +- `isNodeComingFromTestingLibrary` + +Our custom Rule Creator will also take care of common checks like making sure Testing Library is imported, or verify Shared Settings. You don't need to implement anything to check if there is some import related to Testing Library or anything similar in your rule anymore, just stick to the `helpers` received as a 3rd parameter in your `create` function. + +If you need some check related to Testing Library which is not available in any existing helper, feel free to implement a new one. You need to make sure to: + +- add corresponding type +- pass it through `helpers` +- write some generic test within `fake-rule.ts`, which is a dumb rule to be able to test all enhanced behavior from our custom Rule Creator. + +## Updating existing rules A couple of things you need to remember when editing already existing rules: @@ -80,6 +114,32 @@ A couple of things you need to remember when editing already existing rules: - Try to add tests to cover the changes introduced, no matter if that's a bug fix or a new feature. +## Writing Tests + +When writing tests for a new or existing rule, please make sure to follow these guidelines: + +### Write real-ish scenarios + +Since the plugin will report differently depending on which Testing Library package is imported and what Shared Settings are enabled, writing more realistic scenarios is pretty important. Ideally, you should: + +- wrap the code for your rule with a real test file structure, something like + + ```javascript + import { render } from '@testing-library/react'; + + test('should report invalid render usage', () => { + // the following line is the actual code you needed to test your rule, + // but everything else helps finding edge cases and makes it more robust. + const wrapper = render(); + }); + ``` + +- add some extra valid and invalid cases for checking what's the result when some Shared Settings are enabled (so things may or may not be reported depending on the settings), or something named in the same way as a Testing Library util is found, but it's not coming from any Testing Library package (so it shouldn't be reported). + +### Check as much as you can from error reported on invalid test cases + +Please make sure you check `line`, `column`, `messageId` and `data` (if some) in your invalid test cases to check errors are reported as expected. + ## Help needed Please check the [the open issues](https://github.com/testing-library/eslint-plugin-testing-library/issues) diff --git a/README.md b/README.md index 84c6ea6a..6ac66f9b 100644 --- a/README.md +++ b/README.md @@ -23,25 +23,35 @@ [![Tweet][tweet-badge]][tweet-url] + [![All Contributors](https://img.shields.io/badge/all_contributors-36-orange.svg?style=flat-square)](#contributors-) + ## Installation You'll first need to install [ESLint](http://eslint.org): -``` -$ npm i eslint --save-dev +```shell +$ npm install --save-dev eslint +# or +$ yarn add --dev eslint ``` Next, install `eslint-plugin-testing-library`: -``` -$ npm install eslint-plugin-testing-library --save-dev +```shell +$ npm install --save-dev eslint-plugin-testing-library +# or +$ yarn add --dev eslint-plugin-testing-library ``` **Note:** If you installed ESLint globally (using the `-g` flag) then you must also install `eslint-plugin-testing-library` globally. +## Migrating to v4 + +You can find [here a detailed guide for migrating `eslint-plugin-testing-library` to v4](docs/migrating-to-v4-guide.md). + ## Usage Add `testing-library` to the plugins section of your `.eslintrc` configuration file. You can omit the `eslint-plugin-` prefix: @@ -52,51 +62,54 @@ Add `testing-library` to the plugins section of your `.eslintrc` configuration f } ``` -Then configure the rules you want to use under the rules section. +Then configure the rules you want to use within `rules` property of your `.eslintrc`: ```json { "rules": { "testing-library/await-async-query": "error", "testing-library/no-await-sync-query": "error", - "testing-library/no-debug": "warn" + "testing-library/no-debug": "warn", + "testing-library/no-dom-import": "off" } } ``` ## Shareable configurations -### Recommended +This plugin exports several recommended configurations that enforce good practices for specific Testing Library packages. +You can find more info about enabled rules in the [Supported Rules section](#supported-rules), under the `Configurations` column. -This plugin exports a recommended configuration that enforces good -Testing Library practices _(you can find more info about enabled rules in -the [Supported Rules section](#supported-rules) within the `Configurations` column)_. - -To enable this configuration use the `extends` property in your -`.eslintrc` config file: +Since each one of these configurations is aimed at a particular Testing Library package, they are not extendable between them, so you should use only one of them at once per `eslintrc` file. For example, if you want to enable recommended configuration for React, you don't need to combine it somehow with DOM one: ```json +// ❌ Don't do this +{ + "extends": ["plugin:testing-library/dom", "plugin:testing-library/react"] +} + +// ✅ Do just this instead { - "extends": ["plugin:testing-library/recommended"] + "extends": ["plugin:testing-library/react"] } ``` -### Frameworks +### DOM Testing Library -Starting from the premise that -[DOM Testing Library](https://testing-library.com/docs/dom-testing-library/intro) -is the base for the rest of Testing Library frameworks wrappers, this -plugin also exports different configuration for those frameworks that -enforces good practices for specific rules that only apply to them _(you -can find more info about enabled rules in -the [Supported Rules section](#supported-rules) within the `Configurations` column)_. +Enforces recommended rules for DOM Testing Library. -**Note that frameworks configurations enable their specific rules + -recommended rules.** +To enable this configuration use the `extends` property in your +`.eslintrc` config file: -Available frameworks configurations are: +```json +{ + "extends": ["plugin:testing-library/dom"] +} +``` + +### Angular -#### Angular +Enforces recommended rules for Angular Testing Library. To enable this configuration use the `extends` property in your `.eslintrc` config file: @@ -107,7 +120,9 @@ To enable this configuration use the `extends` property in your } ``` -#### React +### React + +Enforces recommended rules for React Testing Library. To enable this configuration use the `extends` property in your `.eslintrc` config file: @@ -118,7 +133,9 @@ To enable this configuration use the `extends` property in your } ``` -#### Vue +### Vue + +Enforces recommended rules for Vue Testing Library. To enable this configuration use the `extends` property in your `.eslintrc` config file: @@ -131,25 +148,32 @@ To enable this configuration use the `extends` property in your ## Supported Rules -| Rule | Description | Configurations | Fixable | -| ---------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------ | -| [await-async-query](docs/rules/await-async-query.md) | Enforce async queries to have proper `await` | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | -| [await-async-utils](docs/rules/await-async-utils.md) | Enforce async utils to be awaited properly | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | -| [await-fire-event](docs/rules/await-fire-event.md) | Enforce async fire event methods to be awaited | ![vue-badge][] | | -| [consistent-data-testid](docs/rules/consistent-data-testid.md) | Ensure `data-testid` values match a provided regex. | | | -| [no-await-sync-events](docs/rules/no-await-sync-events.md) | Disallow unnecessary `await` for sync events | | | -| [no-await-sync-query](docs/rules/no-await-sync-query.md) | Disallow unnecessary `await` for sync queries | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | -| [no-debug](docs/rules/no-debug.md) | Disallow the use of `debug` | ![angular-badge][] ![react-badge][] ![vue-badge][] | | -| [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] | -| [no-manual-cleanup](docs/rules/no-manual-cleanup.md) | Disallow the use of `cleanup` | | | -| [no-render-in-setup](docs/rules/no-render-in-setup.md) | Disallow the use of `render` in setup functions | | | -| [no-wait-for-empty-callback](docs/rules/no-wait-for-empty-callback.md) | Disallow empty callbacks for `waitFor` and `waitForElementToBeRemoved` | | | -| [no-wait-for-snapshot](docs/rules/no-wait-for-snapshot.md) | Ensures no snapshot is generated inside of a `waitFor` call | | | -| [prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | | -| [prefer-find-by](docs/rules/prefer-find-by.md) | Suggest using `findBy*` methods instead of the `waitFor` + `getBy` queries | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] | -| [prefer-presence-queries](docs/rules/prefer-presence-queries.md) | Enforce specific queries when checking element is present or not | | | -| [prefer-screen-queries](docs/rules/prefer-screen-queries.md) | Suggest using screen while using queries | | | -| [prefer-wait-for](docs/rules/prefer-wait-for.md) | Use `waitFor` instead of deprecated wait methods | | ![fixable-badge][] | +| Rule | Description | Configurations | Fixable | +| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------ | +| [testing-library/await-async-query](docs/rules/await-async-query.md) | Enforce promises from async queries to be handled | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | +| [testing-library/await-async-utils](docs/rules/await-async-utils.md) | Enforce async utils to be awaited properly | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | +| [testing-library/await-fire-event](docs/rules/await-fire-event.md) | Enforce promises from fire event methods to be handled | ![vue-badge][] | | +| [testing-library/consistent-data-testid](docs/rules/consistent-data-testid.md) | Ensure `data-testid` values match a provided regex. | | | +| [testing-library/no-await-sync-events](docs/rules/no-await-sync-events.md) | Disallow unnecessary `await` for sync events | | | +| [testing-library/no-await-sync-query](docs/rules/no-await-sync-query.md) | Disallow unnecessary `await` for sync queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | +| [testing-library/no-container](docs/rules/no-container.md) | Disallow the use of `container` methods | ![angular-badge][] ![react-badge][] ![vue-badge][] | | +| [testing-library/no-debug](docs/rules/no-debug.md) | Disallow the use of `debug` | ![angular-badge][] ![react-badge][] ![vue-badge][] | | +| [testing-library/no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] | +| [testing-library/no-manual-cleanup](docs/rules/no-manual-cleanup.md) | Disallow the use of `cleanup` | | | +| [testing-library/no-node-access](docs/rules/no-node-access.md) | Disallow direct Node access | ![angular-badge][] ![react-badge][] ![vue-badge][] | | +| [testing-library/no-promise-in-fire-event](docs/rules/no-promise-in-fire-event.md) | Disallow the use of promises passed to a `fireEvent` method | | | +| [testing-library/no-render-in-setup](docs/rules/no-render-in-setup.md) | Disallow the use of `render` in setup functions | | | +| [testing-library/no-wait-for-empty-callback](docs/rules/no-wait-for-empty-callback.md) | Disallow empty callbacks for `waitFor` and `waitForElementToBeRemoved` | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | +| [testing-library/no-wait-for-multiple-assertions](docs/rules/no-wait-for-multiple-assertions.md) | Disallow the use of multiple expect inside `waitFor` | | | +| [testing-library/no-wait-for-side-effects](docs/rules/no-wait-for-side-effects.md) | Disallow the use of side effects inside `waitFor` | | | +| [testing-library/no-wait-for-snapshot](docs/rules/no-wait-for-snapshot.md) | Ensures no snapshot is generated inside of a `waitFor` call | | | +| [testing-library/prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | | +| [testing-library/prefer-find-by](docs/rules/prefer-find-by.md) | Suggest using `findBy*` methods instead of the `waitFor` + `getBy` queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] | +| [testing-library/prefer-presence-queries](docs/rules/prefer-presence-queries.md) | Enforce specific queries when checking element is present or not | | | +| [testing-library/prefer-user-event](docs/rules/prefer-user-event.md) | Suggest using `userEvent` library instead of `fireEvent` for simulating user interaction | | | +| [testing-library/prefer-screen-queries](docs/rules/prefer-screen-queries.md) | Suggest using screen while using queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | +| [testing-library/prefer-wait-for](docs/rules/prefer-wait-for.md) | Use `waitFor` instead of deprecated wait methods | | ![fixable-badge][] | +| [testing-library/render-result-naming-convention](docs/rules/render-result-naming-convention.md) | Enforce a valid naming for return value from `render` | ![angular-badge][] ![react-badge][] ![vue-badge][] | | [build-badge]: https://github.com/testing-library/eslint-plugin-testing-library/actions/workflows/pipeline.yml/badge.svg [build-url]: https://github.com/testing-library/eslint-plugin-testing-library/actions/workflows/pipeline.yml @@ -165,12 +189,48 @@ To enable this configuration use the `extends` property in your [gh-stars-url]: https://github.com/belco90/eslint-plugin-testing-library/stargazers [tweet-badge]: https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Fgithub.com%2FBelco90%2Feslint-plugin-testing-library [tweet-url]: https://twitter.com/intent/tweet?url=https%3a%2f%2fgithub.com%2fbelco90%2feslint-plugin-testing-library&text=check%20out%20eslint-plugin-testing-library%20by%20@belcodev -[recommended-badge]: https://img.shields.io/badge/recommended-lightgrey?style=flat-square +[dom-badge]: https://img.shields.io/badge/%F0%9F%90%99-DOM-black?style=flat-square [fixable-badge]: https://img.shields.io/badge/fixable-success?style=flat-square [angular-badge]: https://img.shields.io/badge/-Angular-black?style=flat-square&logo=angular&logoColor=white&labelColor=DD0031&color=black [react-badge]: https://img.shields.io/badge/-React-black?style=flat-square&logo=react&logoColor=white&labelColor=61DAFB&color=black [vue-badge]: https://img.shields.io/badge/-Vue-black?style=flat-square&logo=vue.js&logoColor=white&labelColor=4FC08D&color=black +## Shared Settings + +There are some configuration options available that will be shared across all the plugin rules. This is achieved using [ESLint Shared Settings](https://eslint.org/docs/user-guide/configuring/configuration-files#adding-shared-settings). These Shared Settings are meant to be used if you need to restrict the Aggressive Reporting mechanism, which is an out of the box advanced feature to lint Testing Library usages in a simpler way for most of the users. **So please before configuring any of these settings**, read more about [the advantages of `eslint-plugin-testing-library` Aggressive Reporting mechanism](docs/migrating-to-v4-guide.md#aggressive-reporting), and [how it's affected by these settings](docs/migrating-to-v4-guide.md#shared-settings). + +If you are sure about configuring the settings, these are the options available: + +### `testing-library/utils-module` + +The name of your custom utility file from where you re-export everything from Testing Library package. + +```json +// .eslintrc +{ + "settings": { + "testing-library/utils-module": "my-custom-test-utility-file" + } +} +``` + +[You can find more details here](docs/migrating-to-v4-guide.md#testing-libraryutils-module). + +### `testing-library/custom-renders` + +A list of function names that are valid as Testing Library custom renders. Relates to [Aggressive Reporting - Renders](docs/migrating-to-v4-guide.md#renders) + +```json +// .eslintrc +{ + "settings": { + "testing-library/custom-renders": ["display", "renderWithProviders"] + } +} +``` + +[You can find more details here](docs/migrating-to-v4-guide.md#testing-librarycustom-renders). + ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): diff --git a/docs/migrating-to-v4-guide.md b/docs/migrating-to-v4-guide.md new file mode 100644 index 00000000..050de6cc --- /dev/null +++ b/docs/migrating-to-v4-guide.md @@ -0,0 +1,271 @@ +# Guide: migrating to v4 + +Previous versions of `eslint-plugin-testing-library` weren't checking common things consistently: Testing Library imports, renamed methods, wrappers around Testing Library methods, etc. +One of the most important changes of `eslint-plugin-testing-library` v4 is the new detection mechanism implemented to be shared across all the rules, so each one of them has been rewritten to detect and report Testing Library usages consistently and correctly from a core module. + +## Overview + +- [Aggressive Reporting](#aggressive-reporting) opted-in to avoid silencing possible errors +- 7 new rules + - `no-container` + - `no-node-access` + - `no-promise-in-fire-event` + - `no-wait-for-multiple-assertions` + - `no-wait-for-side-effects` + - `prefer-user-event` + - `render-result-naming-convention` +- Shareable Configs updated + - `recommended` renamed to `dom` + - list of rules enabled has changed +- Some rules option removed in favor of new Shared Settings + Aggressive Reporting +- More consistent and flexible core rules detection +- Tons of errors and small issues fixed +- Dependencies updates + +## Breaking Changes + +### Dependencies + +- Min ESLint version required is `7.5` +- Min Node version required didn't change (`10.12`) + +Please make sure you have Node and ESLint installed satisfying these required versions. + +### New errors reported + +Since v4 also fixes a lot of issues and detect invalid usages in a more consistent way, you might find new errors reported in your codebase. Just be aware of this when migrating to v4. + +### `recommended` Shareable Config has been renamed + +If you were using `recommended` Shareable Config, it has been renamed to `dom` so you'll need to update it in your ESLint config file: + +```diff +{ + ... +- "extends": ["plugin:testing-library/recommended"] ++ "extends": ["plugin:testing-library/dom"] +} +``` + +This Shareable Config has been renamed to clarify there is no _recommended_ config by default, so it depends on which Testing Library package you are using: DOM, Angular, React, or Vue (for now). + +### Shareable Configs updated + +Shareable Configs have been updated with: + +- `dom` + - `no-promise-in-fire-event` enabled as "error" + - `no-wait-for-empty-callback` enabled as "error" + - `prefer-screen-queries` enabled as "error" +- `angular` + - `no-container` enabled as "error" + - `no-debug` changed from "warning" to "error" + - `no-node-access` enabled as "error" + - `no-promise-in-fire-event` enabled as "error" + - `no-wait-for-empty-callback` enabled as "error" + - `prefer-screen-queries` enabled as "error" + - `render-result-naming-convention` enabled as "error" +- `react` + - `no-container` enabled as "error" + - `no-debug` changed from "warning" to "error" + - `no-node-access` enabled as "error" + - `no-promise-in-fire-event` enabled as "error" + - `no-wait-for-empty-callback` enabled as "error" + - `prefer-screen-queries` enabled as "error" + - `render-result-naming-convention` enabled as "error" +- `vue` + - `no-container` enabled as "error" + - `no-debug` changed from "warning" to "error" + - `no-node-access` enabled as "error" + - `no-promise-in-fire-event` enabled as "error" + - `no-wait-for-empty-callback` enabled as "error" + - `prefer-screen-queries` enabled as "error" + - `render-result-naming-convention` enabled as "error" + +### `customQueryNames` rules option has been removed + +Until now, those rules reporting errors related to Testing Library queries needed an option called `customQueryNames` so you could specify which extra queries you'd like to report apart from built-in ones. This option has been removed in favor of reporting every method matching Testing Library queries pattern. The only thing you need to do is removing `customQueryNames` from your rules config if any. You can read more about it in corresponding [Aggressive Reporting - Queries](#queries) section. + +### `renderFunctions` rules option has been removed + +Until now, those rules reporting errors related to Testing Library `render` needed an option called `renderFunctions` so you could specify which extra functions from your codebase should be assumed as extra `render` methods apart from built-in one. This option has been removed in favor of reporting every method which contains `*render*` on its name. The only thing you need to do is removing `renderFunctions` from your rules config if any. You can read more about it in corresponding [Aggressive Reporting - Render](#renders) section, and available config in [Shared Settings](#shared-settings) section. + +## Aggressive Reporting + +So what is this Aggressive Reporting introduced on v4? Until v3, `eslint-plugin-testing-library` had assumed that all Testing Libraries utils would be imported from some `@testing-library/*` or `*-testing-library` package. However, this is not always true since: + +- users can [add their own Custom Render](https://testing-library.com/docs/react-testing-library/setup/#custom-render) methods, so it can be named other than `render`. +- users can [re-export Testing Library utils from a custom module](https://testing-library.com/docs/react-testing-library/setup/#configuring-jest-with-test-utils), so they won't be imported from a Testing Library package but a custom one. +- users can [add their own Custom Queries](https://testing-library.com/docs/react-testing-library/setup/#add-custom-queries), so it's possible to use other queries than built-in ones. + +These customization mechanisms make impossible for `eslint-plugin-testing-library` to figure out if some utils are related to Testing Library or not. Here you have some examples illustrating it: + +```javascript +import { render, screen } from '@testing-library/react'; +// ... + +// ✅ this render has to be reported since it's named `render` +// and it's imported from @testing-library/* package +const wrapper = render(); + +// ✅ this query has to be reported since it's named after a built-in query +// and it's imported from @testing-library/* package +const el = screen.findByRole('button'); +``` + +```javascript +// importing from Custom Module +import { renderWithRedux, findByIcon } from 'test-utils'; +// ... + +// ❓ we don't know if this render has to be reported since it's NOT named `render` +// and it's NOT imported from @testing-library/* package +const wrapper = renderWithRedux(); + +// ❓ we don't know if this query has to be reported since it's NOT named after a built-in query +// and it's NOT imported from @testing-library/* package +const el = findByIcon('profile'); +``` + +How can the `eslint-plugin-testing-library` be aware of this? Until v3, the plugin offered some options to indicate some of these custom things, so the plugin would check them when reporting usages. This can lead to false negatives though since the users might not be aware of the necessity of indicating such custom utils or just forget about doing so. + +Instead, in `eslint-plugin-testing-library` v4 we have opted-in a more **aggressive reporting** mechanism which, by default, will assume any method named following the same patterns as Testing Library utils has to be reported too: + +```javascript +// importing from Custom Module +import { renderWithRedux, findByIcon } from 'test-utils'; +// ... + +// ✅ this render has to be reported since its name contains "*render*" +// and it doesn't matter where it's imported from +const wrapper = renderWithRedux(); + +// ✅ this render has to be reported since its name starts by "findBy*" +// and it doesn't matter where it's imported from +const el = findByIcon('profile'); +``` + +There are 3 behaviors then that can be aggressively reported: imports, renders, and queries. This new Aggressive Reporting mechanism will just work fine out of the box and won't create false positives for most of the users. However, it's possible to do some tweaks to disable some of these behaviors using the new [Shared Settings](#shared-settings). We recommend you to keep reading this section to know more about these Aggressive Reporting behaviors and then check the Shared Settings if you think you'd still need it for some particular reason. + +_You can find the motivation behind this behavior on [this issue comment](https://github.com/testing-library/eslint-plugin-testing-library/issues/222#issuecomment-679592434)._ + +### Imports + +By default, `eslint-plugin-testing-library` v4 won't check from which module are the utils imported. This means it doesn't matter if you are importing the utils from `@testing-library/*`, `test-utils` or `whatever`. + +There is a new Shared Setting to restrict this scope though: [`utils-module`](#testing-libraryutils-module). By using this setting, only utils imported from `@testing-library/*` packages, or the custom one indicated in this setting would be reported. + +### Renders + +By default, `eslint-plugin-testing-library` v4 will assume that all methods which names contain "render" should be reported. This means it doesn't matter if you are rendering your elements for testing using `render`, `customRender` or `renderWithRedux`. + +There is a new Shared Setting to restrict this scope though: [`custom-renders`](#testing-librarycustom-renders). By using this setting, only methods strictly named `render` or as one of the indicated Custom Renders would be reported. + +### Queries + +`eslint-plugin-testing-library` v4 will assume that all methods named following the pattern `get(All)By*`, `query(All)By*`, or `find(All)By*` are queries to be reported. This means it doesn't matter if you are using a built-in query (`getByText`), or a custom one (`getByIcon`): if it matches this pattern, it will be assumed as a potential query to be reported. + +There is no way to restrict this behavior for now. + +## Shared Settings + +ESLint has a setting feature which allows configuring data that must be shared across all its rules: [Shared Settings](https://eslint.org/docs/user-guide/configuring/configuration-files#adding-shared-settings). Since `eslint-plugin-testing-library` v4 we are using this Shared Settings to config global things for the plugin. + +To avoid collision with settings from other ESLint plugins, all the properties for this one are prefixed with `testing-library/`. + +⚠️ **Please be aware of using these settings will disable part of [Aggressive Reporting](#aggressive-reporting).** + +### `testing-library/utils-module` + +The name of your custom utility file from where you re-export everything from Testing Library package. Relates to [Aggressive Reporting - Imports](#imports). + +```json +// .eslintrc +{ + "settings": { + "testing-library/utils-module": "my-custom-test-utility-file" + } +} +``` + +Enabling this setting, you'll restrict the errors reported by the plugin to only those utils being imported from this custom utility file, or some `@testing-library/*` package. The previous setting example would cause: + +```javascript +import { waitFor } from '@testing-library/react'; + +test('testing-library/utils-module setting example', () => { + // ✅ this would be reported since this invalid usage of an util + // is imported from `@testing-library/*` package + waitFor(/* some invalid usage to be reported */); +}); +``` + +```javascript +import { waitFor } from '../my-custom-test-utility-file'; + +test('testing-library/utils-module setting example', () => { + // ✅ this would be reported since this invalid usage of an util + // is imported from specified custom utility file. + waitFor(/* some invalid usage to be reported */); +}); +``` + +```javascript +import { waitFor } from '../somewhere-else'; + +test('testing-library/utils-module setting example', () => { + // ❌ this would NOT be reported since this invalid usage of an util + // is NOT imported from either `@testing-library/*` package or specified custom utility file. + waitFor(/* some invalid usage to be reported */); +}); +``` + +### `testing-library/custom-renders` + +A list of function names that are valid as Testing Library custom renders. Relates to [Aggressive Reporting - Renders](#renders) + +```json +// .eslintrc +{ + "settings": { + "testing-library/custom-renders": ["display", "renderWithProviders"] + } +} +``` + +Enabling this setting, you'll restrict the errors reported by the plugin related to `render` somehow to only those functions sharing a name with one of the elements of that list, or built-in `render`. The previous setting example would cause: + +```javascript +import { + render, + display, + renderWithProviders, + renderWithRedux, +} from 'test-utils'; +import Component from 'somewhere'; + +const setupA = () => renderWithProviders(); +const setupB = () => renderWithRedux(); + +test('testing-library/custom-renders setting example', () => { + // ✅ this would be reported since `render` is a built-in Testing Library util + const invalidUsage = render(); + + // ✅ this would be reported since `display` has been set as `custom-render` + const invalidUsage = display(); + + // ✅ this would be reported since `renderWithProviders` has been set as `custom-render` + const invalidUsage = renderWithProviders(); + + // ❌ this would NOT be reported since `renderWithRedux` isn't a `custom-render` or built-in one + const invalidUsage = renderWithRedux(); + + // ✅ this would be reported since it wraps `renderWithProviders`, + // which has been set as `custom-render` + const invalidUsage = setupA(); + + // ❌ this would NOT be reported since it wraps `renderWithRedux`, + // which isn't a `custom-render` or built-in one + const invalidUsage = setupB(); +}); +``` diff --git a/docs/rules/await-async-query.md b/docs/rules/await-async-query.md index 493b7d03..c0f8081f 100644 --- a/docs/rules/await-async-query.md +++ b/docs/rules/await-async-query.md @@ -1,75 +1,100 @@ -# Enforce async queries to have proper `await` (await-async-query) +# Enforce promises from async queries to be handled (`testing-library/await-async-query`) Ensure that promises returned by async queries are handled properly. ## Rule Details -Some of the queries variants that Testing Library provides are +Some queries variants that Testing Library provides are asynchronous as they return a promise which resolves when elements are found. Those queries variants are: - `findBy*` - `findAllBy*` -This rule aims to prevent users from forgetting to await the returned -promise from those async queries to be fulfilled, which could lead to -errors in the tests. The promises can be handled by using either `await` -operator or `then` method. +This rule aims to prevent users from forgetting to handle the returned +promise from those async queries, which could lead to +problems in the tests. The promise will be considered as handled when: + +- using the `await` operator +- wrapped within `Promise.all` or `Promise.allSettled` methods +- chaining the `then` method +- chaining `resolves` or `rejects` from jest +- it's returned from a function (in this case, that particular function will be analyzed by this rule too) Examples of **incorrect** code for this rule: ```js -const foo = () => { - // ... - const rows = findAllByRole('row'); - // ... -}; - -const bar = () => { - // ... - findByText('submit'); - // ... -}; - -const baz = () => { - // ... - screen.findAllByPlaceholderText('name'); - // ... -}; +// async query without handling promise +const rows = findAllByRole('row'); + +findByIcon('search'); + +screen.findAllByPlaceholderText('name'); +``` + +```js +// promise from async query returned within wrapper function without being handled +const findMyButton = () => findByText('my button'); + +const someButton = findMyButton(); // promise unhandled here ``` Examples of **correct** code for this rule: ```js // `await` operator is correct -const foo = async () => { - // ... - const rows = await findAllByRole('row'); - // ... -}; +const rows = await findAllByRole('row'); +await screen.findAllByPlaceholderText('name'); + +const promise = findByIcon('search'); +const element = await promise; +``` + +```js // `then` method is correct -const bar = () => { - // ... - findByText('submit').then(() => { - // ... - }); -}; - -const baz = () => { - // ... - await screen.findAllByPlaceholderText('name'); - // ... -}; +findByText('submit').then(() => {}); +const promise = findByRole('button'); +promise.then(() => {}); +``` + +```js // return the promise within a function is correct too! const findMyButton = () => findByText('my button'); +``` +```js +// promise from async query returned within wrapper function being handled +const findMyButton = () => findByText('my button'); + +const someButton = await findMyButton(); +``` + +```js +// several promises handled with `Promise.all` is correct +await Promise.all([findByText('my button'), findByText('something else')]); +``` + +```js +// several promises handled `Promise.allSettled` is correct +await Promise.allSettled([ + findByText('my button'), + findByText('something else'), +]); +``` + +```js // using a resolves/rejects matcher is also correct expect(findByTestId('alert')).resolves.toBe('Success'); expect(findByTestId('alert')).rejects.toBe('Error'); ``` +```js +// sync queries don't need to handle any promise +const element = getByRole('role'); +``` + ## Further Reading - [Async queries variants](https://testing-library.com/docs/dom-testing-library/api-queries#findby) diff --git a/docs/rules/await-async-utils.md b/docs/rules/await-async-utils.md index 7dbd53ca..9d23ab41 100644 --- a/docs/rules/await-async-utils.md +++ b/docs/rules/await-async-utils.md @@ -1,4 +1,4 @@ -# Enforce async utils to be awaited properly (await-async-utils) +# Enforce promises from async utils to be handled (`testing-library/await-async-utils`) Ensure that promises returned by async utils are handled properly. @@ -6,13 +6,21 @@ Ensure that promises returned by async utils are handled properly. 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: -- `waitFor` _(introduced in dom-testing-library v7)_ +- `waitFor` _(introduced since dom-testing-library v7)_ - `waitForElementToBeRemoved` -- `wait` _(**deprecated** in dom-testing-library v7)_ -- `waitForElement` _(**deprecated** in dom-testing-library v7)_ -- `waitForDomChange` _(**deprecated** in dom-testing-library v7)_ +- `wait` _(**deprecated** since dom-testing-library v7)_ +- `waitForElement` _(**deprecated** since dom-testing-library v7)_ +- `waitForDomChange` _(**deprecated** since dom-testing-library v7)_ -This rule aims to prevent users from forgetting to handle the returned promise from those async utils, which could lead to unexpected errors in the tests execution. The promises can be handled by using either `await` operator or `then` method. +This rule aims to prevent users from forgetting to handle the returned +promise from async utils, which could lead to +problems in the tests. The promise will be considered as handled when: + +- using the `await` operator +- wrapped within `Promise.all` or `Promise.allSettled` methods +- chaining the `then` method +- chaining `resolves` or `rejects` from jest +- it's returned from a function (in this case, that particular function will be analyzed by this rule too) Examples of **incorrect** code for this rule: @@ -32,6 +40,14 @@ test('something incorrectly', async () => { waitFor(() => {}, { timeout: 100 }); waitForElementToBeRemoved(() => document.querySelector('div.getOuttaHere')); + + // wrap an async util within a function... + const makeCustomWait = () => { + return waitForElementToBeRemoved(() => + document.querySelector('div.getOuttaHere') + ); + }; + makeCustomWait(); // ...but not handling promise from it is incorrect }); ``` @@ -54,11 +70,15 @@ test('something correctly', async () => { // `then` chained method is correct waitFor(() => {}, { timeout: 100 }) .then(() => console.log('DOM changed!')) - .catch(err => console.log(`Error you need to deal with: ${err}`)); - - // return the promise within a function is correct too! - const makeCustomWait = () => - waitForElementToBeRemoved(() => document.querySelector('div.getOuttaHere')); + .catch((err) => console.log(`Error you need to deal with: ${err}`)); + + // wrap an async util within a function... + const makeCustomWait = () => { + return waitForElementToBeRemoved(() => + document.querySelector('div.getOuttaHere') + ); + }; + await makeCustomWait(); // ...and handling promise from it is correct // using Promise.all combining the methods await Promise.all([ diff --git a/docs/rules/await-fire-event.md b/docs/rules/await-fire-event.md index 1f464de5..65e28594 100644 --- a/docs/rules/await-fire-event.md +++ b/docs/rules/await-fire-event.md @@ -1,12 +1,17 @@ -# Enforce async fire event methods to be awaited (await-fire-event) +# Enforce promises from fire event methods to be handled (`testing-library/await-fire-event`) -Ensure that promises returned by `fireEvent` methods are awaited +Ensure that promises returned by `fireEvent` methods are handled properly. ## Rule Details -This rule aims to prevent users from forgetting to await `fireEvent` -methods when they are async. +This rule aims to prevent users from forgetting to handle promise returned from `fireEvent` +methods. + +> ⚠️ `fireEvent` methods are async only on following Testing Library packages: +> +> - `@testing-library/vue` (supported by this plugin) +> - `@testing-library/svelte` (not supported yet by this plugin) Examples of **incorrect** code for this rule: @@ -15,6 +20,12 @@ fireEvent.click(getByText('Click me')); fireEvent.focus(getByLabelText('username')); fireEvent.blur(getByLabelText('username')); + +// wrap a fireEvent method within a function... +function triggerEvent() { + return fireEvent.click(button); +} +triggerEvent(); // ...but not handling promise from it is incorrect too ``` Examples of **correct** code for this rule: @@ -30,15 +41,24 @@ fireEvent.click(getByText('Click me')).then(() => { }); // return the promise within a function is correct too! -function clickMeRegularFn() { - return fireEvent.click(getByText('Click me')); -} const clickMeArrowFn = () => fireEvent.click(getByText('Click me')); + +// wrap a fireEvent method within a function... +function triggerEvent() { + return fireEvent.click(button); +} +await triggerEvent(); // ...and handling promise from it is correct also + +// using `Promise.all` or `Promise.allSettled` with an array of promises is valid +await Promise.all([ + fireEvent.focus(getByLabelText('username')), + fireEvent.blur(getByLabelText('username')), +]); ``` ## When Not To Use It -`fireEvent` methods are only async in Vue Testing Library so if you are using another Testing Library module, you shouldn't use this rule. +`fireEvent` methods are not async on all Testing Library packages. If you are not using Testing Library package with async fire event, you shouldn't use this rule. ## Further Reading diff --git a/docs/rules/consistent-data-testid.md b/docs/rules/consistent-data-testid.md index de58f52b..9b03f2ae 100644 --- a/docs/rules/consistent-data-testid.md +++ b/docs/rules/consistent-data-testid.md @@ -1,4 +1,4 @@ -# Enforces consistent naming for the data-testid attribute (consistent-data-testid) +# Enforces consistent naming for the data-testid attribute (`testing-library/consistent-data-testid`) Ensure `data-testid` values match a provided regex. This rule is un-opinionated, and requires configuration. @@ -9,17 +9,17 @@ Ensure `data-testid` values match a provided regex. This rule is un-opinionated, Examples of **incorrect** code for this rule: ```js -const foo = props =>
...
; -const foo = props =>
...
; -const foo = props =>
...
; +const foo = (props) =>
...
; +const foo = (props) =>
...
; +const foo = (props) =>
...
; ``` Examples of **correct** code for this rule: ```js -const foo = props =>
...
; -const bar = props =>
...
; -const baz = props =>
...
; +const foo = (props) =>
...
; +const bar = (props) =>
...
; +const baz = (props) =>
...
; ``` ## Options diff --git a/docs/rules/no-await-sync-events.md b/docs/rules/no-await-sync-events.md index 9eb61548..58206373 100644 --- a/docs/rules/no-await-sync-events.md +++ b/docs/rules/no-await-sync-events.md @@ -1,14 +1,15 @@ -# Disallow unnecessary `await` for sync events (no-await-sync-events) +# Disallow unnecessary `await` for sync events (`testing-library/no-await-sync-events`) -Ensure that sync events are not awaited unnecessarily. +Ensure that sync simulated events are not awaited unnecessarily. ## Rule Details -Functions in the event object provided by Testing Library, including -fireEvent and userEvent, do NOT return Promise, with an exception of -`userEvent.type`, which delays the promise resolve only if [`delay` +Methods for simulating events in Testing Library ecosystem -`fireEvent` and `userEvent`- +do NOT return any Promise, with an exception of +`userEvent.type` and `userEvent.keyboard`, which delays the promise resolve only if [`delay` option](https://github.com/testing-library/user-event#typeelement-text-options) is specified. -Some examples are: + +Some examples of simulating events not returning any Promise are: - `fireEvent.click` - `fireEvent.select` @@ -26,15 +27,16 @@ const foo = async () => { // ... }; -const bar = () => { +const bar = async () => { // ... await userEvent.tab(); // ... }; -const baz = () => { +const baz = async () => { // ... await userEvent.type(textInput, 'abc'); + await userEvent.keyboard('abc'); // ... }; ``` @@ -54,10 +56,14 @@ const bar = () => { // ... }; -const baz = () => { +const baz = async () => { // await userEvent.type only with delay option - await userEvent.type(textInput, 'abc', {delay: 1000}); + await userEvent.type(textInput, 'abc', { delay: 1000 }); userEvent.type(textInput, '123'); + + // same for userEvent.keyboard + await userEvent.keyboard(textInput, 'abc', { delay: 1000 }); + userEvent.keyboard('123'); // ... }; ``` diff --git a/docs/rules/no-await-sync-query.md b/docs/rules/no-await-sync-query.md index b84f117c..09424258 100644 --- a/docs/rules/no-await-sync-query.md +++ b/docs/rules/no-await-sync-query.md @@ -1,4 +1,4 @@ -# Disallow unnecessary `await` for sync queries (no-await-sync-query) +# Disallow unnecessary `await` for sync queries (`testing-library/no-await-sync-query`) Ensure that sync queries are not awaited unnecessarily. diff --git a/docs/rules/no-container.md b/docs/rules/no-container.md new file mode 100644 index 00000000..c764c641 --- /dev/null +++ b/docs/rules/no-container.md @@ -0,0 +1,38 @@ +# Disallow the use of `container` methods (`testing-library/no-container`) + +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. + +This applies to Testing Library frameworks built on top of **DOM Testing Library** + +## Rule Details + +This rule aims to disallow the use of `container` methods in your tests. + +Examples of **incorrect** code for this rule: + +```js +const { container } = render(); +const button = container.querySelector('.btn-primary'); +``` + +```js +const { container: alias } = render(); +const button = alias.querySelector('.btn-primary'); +``` + +```js +const view = render(); +const button = view.container.getElementsByClassName('.btn-primary'); +``` + +Examples of **correct** code for this rule: + +```js +render(); +screen.getByRole('button', { name: /click me/i }); +``` + +## Further Reading + +- [about the `container` element](https://testing-library.com/docs/react-testing-library/api#container-1) +- [querying with `screen`](https://testing-library.com/docs/dom-testing-library/api-queries#screen) diff --git a/docs/rules/no-debug.md b/docs/rules/no-debug.md index ef848d85..1b3167fd 100644 --- a/docs/rules/no-debug.md +++ b/docs/rules/no-debug.md @@ -1,6 +1,6 @@ -# Disallow the use of `debug` (no-debug) +# Disallow the use of `debug` (`testing-library/no-debug`) -Just like `console.log` statements pollutes the browser's output, debug statements also pollutes the tests if one of your team mates 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. +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. ## Rule Details @@ -28,12 +28,6 @@ const { screen } = require('@testing-library/react'); screen.debug(); ``` -If you use [custom render functions](https://testing-library.com/docs/example-react-redux) then you can set a config option in your `.eslintrc` to look for these. - -``` - "testing-library/no-debug": ["error", {"renderFunctions":["renderWithRedux", "renderWithRouter"]}], -``` - ## Further Reading - [debug API in React Testing Library](https://testing-library.com/docs/react-testing-library/api#debug) diff --git a/docs/rules/no-dom-import.md b/docs/rules/no-dom-import.md index e6e9e000..a7ba4e1b 100644 --- a/docs/rules/no-dom-import.md +++ b/docs/rules/no-dom-import.md @@ -1,4 +1,4 @@ -# Disallow importing from DOM Testing Library +# Disallow importing from DOM Testing Library (`testing-library/no-dom-import`) Ensure that there are no direct imports from `@testing-library/dom` or `dom-testing-library` when using some testing library framework @@ -7,18 +7,18 @@ wrapper. ## Rule Details Testing Library framework wrappers as React Testing Library already -re-exports everything from DOM Testing Library so you always have to -import DOM Testing Library utils from corresponding framework wrapper +re-exports everything from DOM Testing Library, so you always have to +import Testing Library utils from corresponding framework wrapper module to: - use proper extended version of some of those methods containing additional functionality related to specific framework (e.g. `fireEvent` util) - avoid importing from extraneous dependencies (similar to - eslint-plugin-import) + `eslint-plugin-import`) This rule aims to prevent users from import anything directly from -`@testing-library/dom` (or `dom-testing-library`) and it's useful for +`@testing-library/dom`, which is useful for new starters or when IDEs autoimport from wrong module. Examples of **incorrect** code for this rule: diff --git a/docs/rules/no-manual-cleanup.md b/docs/rules/no-manual-cleanup.md index cfdd84d8..4b63f88f 100644 --- a/docs/rules/no-manual-cleanup.md +++ b/docs/rules/no-manual-cleanup.md @@ -1,4 +1,4 @@ -# Disallow the use of `cleanup` (no-manual-cleanup) +# Disallow the use of `cleanup` (`testing-library/no-manual-cleanup`) `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. diff --git a/docs/rules/no-node-access.md b/docs/rules/no-node-access.md new file mode 100644 index 00000000..638f06eb --- /dev/null +++ b/docs/rules/no-node-access.md @@ -0,0 +1,60 @@ +# Disallow direct Node access (`testing-library/no-node-access`) + +The Testing Library already provides methods for querying DOM elements. + +## Rule Details + +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. + +Examples of **incorrect** code for this rule: + +```js +import { screen } from '@testing-library/react'; + +screen.getByText('Submit').closest('button'); // chaining with Testing Library methods +``` + +```js +import { screen } from '@testing-library/react'; + +const buttons = screen.getAllByRole('button'); +expect(buttons[1].lastChild).toBeInTheDocument(); +``` + +```js +import { screen } from '@testing-library/react'; + +const buttonText = screen.getByText('Submit'); +const button = buttonText.closest('button'); +``` + +Examples of **correct** code for this rule: + +```js +import { screen } from '@testing-library/react'; + +const button = screen.getByRole('button'); +expect(button).toHaveTextContent('submit'); +``` + +```js +import { render, within } from '@testing-library/react'; + +const { getByLabelText } = render(); +const signinModal = getByLabelText('Sign In'); +within(signinModal).getByPlaceholderText('Username'); +``` + +```js +// If is not importing a testing-library package + +document.getElementById('submit-btn').closest('button'); +``` + +## Further Reading + +### Properties / methods that return another Node + +- [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document) +- [`Element`](https://developer.mozilla.org/en-US/docs/Web/API/Element) +- [`Node`](https://developer.mozilla.org/en-US/docs/Web/API/Node) diff --git a/docs/rules/no-promise-in-fire-event.md b/docs/rules/no-promise-in-fire-event.md new file mode 100644 index 00000000..3bd5dd5f --- /dev/null +++ b/docs/rules/no-promise-in-fire-event.md @@ -0,0 +1,47 @@ +# Disallow the use of promises passed to a `fireEvent` method (`testing-library/no-promise-in-fire-event`) + +Methods from `fireEvent` expect to receive a DOM element. Passing a promise will end up in an error, so it must be prevented. + +Examples of **incorrect** code for this rule: + +```js +import { screen, fireEvent } from '@testing-library/react'; + +// usage of unhandled findBy queries +fireEvent.click(screen.findByRole('button')); + +// usage of unhandled promises +fireEvent.click(new Promise(jest.fn())); + +// usage of references to unhandled promises +const promise = new Promise(); +fireEvent.click(promise); + +const anotherPromise = screen.findByRole('button'); +fireEvent.click(anotherPromise); +``` + +Examples of **correct** code for this rule: + +```js +import { screen, fireEvent } from '@testing-library/react'; + +// usage of getBy queries +fireEvent.click(screen.getByRole('button')); + +// usage of awaited findBy queries +fireEvent.click(await screen.findByRole('button')); + +// usage of references to handled promises +const promise = new Promise(); +const element = await promise; +fireEvent.click(element); + +const anotherPromise = screen.findByRole('button'); +const button = await anotherPromise; +fireEvent.click(button); +``` + +## Further Reading + +- [A Github Issue explaining the problem](https://github.com/testing-library/dom-testing-library/issues/609) diff --git a/docs/rules/no-render-in-setup.md b/docs/rules/no-render-in-setup.md index d3ef5805..91ec364f 100644 --- a/docs/rules/no-render-in-setup.md +++ b/docs/rules/no-render-in-setup.md @@ -1,8 +1,8 @@ -# Disallow the use of `render` in setup functions (no-render-in-setup) +# Disallow the use of `render` in setup functions (`testing-library/no-render-in-setup`) ## Rule Details -This rule disallows the usage of `render` (or a custom render function) in setup functions (`beforeEach` and `beforeAll`) in favor of moving `render` closer to test assertions. +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. Examples of **incorrect** code for this rule: @@ -20,6 +20,22 @@ it('Should have bar', () => { }); ``` +```js +const setup = () => render(); + +beforeEach(() => { + setup(); +}); + +it('Should have foo', () => { + expect(screen.getByText('foo')).toBeInTheDocument(); +}); + +it('Should have bar', () => { + expect(screen.getByText('bar')).toBeInTheDocument(); +}); +``` + ```js beforeAll(() => { render(); @@ -44,10 +60,18 @@ it('Should have foo and bar', () => { }); ``` -If you use [custom render functions](https://testing-library.com/docs/example-react-redux) then you can set a config option in your `.eslintrc` to look for these. +```js +const setup = () => render(); -``` - "testing-library/no-render-in-setup": ["error", {"renderFunctions": ["renderWithRedux", "renderWithRouter"]}], +beforeEach(() => { + // other stuff... +}); + +it('Should have foo and bar', () => { + setup(); + expect(screen.getByText('foo')).toBeInTheDocument(); + expect(screen.getByText('bar')).toBeInTheDocument(); +}); ``` 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"`. diff --git a/docs/rules/no-wait-for-empty-callback.md b/docs/rules/no-wait-for-empty-callback.md index d33bfa83..2754f710 100644 --- a/docs/rules/no-wait-for-empty-callback.md +++ b/docs/rules/no-wait-for-empty-callback.md @@ -1,4 +1,4 @@ -# Empty callbacks inside `waitFor` and `waitForElementToBeRemoved` are not preferred (no-wait-for-empty-callback) +# Empty callbacks inside `waitFor` and `waitForElementToBeRemoved` are not preferred (`testing-library/no-wait-for-empty-callback`) ## Rule Details @@ -11,11 +11,11 @@ Examples of **incorrect** code for this rule: ```js const foo = async () => { await waitFor(() => {}); - await waitFor(function() {}); + await waitFor(function () {}); await waitFor(noop); await waitForElementToBeRemoved(() => {}); - await waitForElementToBeRemoved(function() {}); + await waitForElementToBeRemoved(function () {}); await waitForElementToBeRemoved(noop); }; ``` diff --git a/docs/rules/no-wait-for-multiple-assertions.md b/docs/rules/no-wait-for-multiple-assertions.md new file mode 100644 index 00000000..efe376ca --- /dev/null +++ b/docs/rules/no-wait-for-multiple-assertions.md @@ -0,0 +1,52 @@ +# Disallow the use of multiple expect inside `waitFor` (`testing-library/no-wait-for-multiple-assertions`) + +## Rule Details + +This rule aims to ensure the correct usage of `expect` inside `waitFor`, in the way that they're intended to be used. +When using multiples assertions inside `waitFor`, if one fails, you have to wait for a timeout before seeing it failing. +Putting one assertion, you can both wait for the UI to settle to the state you want to assert on, +and also fail faster if one of the assertions do end up failing + +Example of **incorrect** code for this rule: + +```js +const foo = async () => { + await waitFor(() => { + expect(a).toEqual('a'); + expect(b).toEqual('b'); + }); + + // or + await waitFor(function () { + expect(a).toEqual('a'); + expect(b).toEqual('b'); + }); +}; +``` + +Examples of **correct** code for this rule: + +```js +const foo = async () => { + await waitFor(() => expect(a).toEqual('a')); + expect(b).toEqual('b'); + + // or + await waitFor(function () { + expect(a).toEqual('a'); + }); + expect(b).toEqual('b'); + + // it only detects expect + // so this case doesn't generate warnings + await waitFor(() => { + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(b).toEqual('b'); + }); +}; +``` + +## Further Reading + +- [about `waitFor`](https://testing-library.com/docs/dom-testing-library/api-async#waitfor) +- [inspiration for this rule](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#having-multiple-assertions-in-a-single-waitfor-callback) diff --git a/docs/rules/no-wait-for-side-effects.md b/docs/rules/no-wait-for-side-effects.md new file mode 100644 index 00000000..6f81179d --- /dev/null +++ b/docs/rules/no-wait-for-side-effects.md @@ -0,0 +1,71 @@ +# Disallow the use of side effects inside `waitFor` (`testing-library/no-wait-for-side-effects`) + +## Rule Details + +This rule aims to avoid the usage of side effects actions (`fireEvent` or `userEvent`) inside `waitFor`. +Since `waitFor` is intended for things that have a non-deterministic amount of time between the action you performed and the assertion passing, +the callback can be called (or checked for errors) a non-deterministic number of times and frequency. +This will make your side-effect run multiple times. + +Example of **incorrect** code for this rule: + +```js + await waitFor(() => { + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(b).toEqual('b'); + }); + + // or + await waitFor(function() { + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(b).toEqual('b'); + }); + + // or + await waitFor(() => { + userEvent.click(button); + expect(b).toEqual('b'); + }); + + // or + await waitFor(function() { + userEvent.click(button); + expect(b).toEqual('b'); + }); +}; +``` + +Examples of **correct** code for this rule: + +```js + fireEvent.keyDown(input, { key: 'ArrowDown' }); + await waitFor(() => { + expect(b).toEqual('b'); + }); + + // or + fireEvent.keyDown(input, { key: 'ArrowDown' }); + await waitFor(function() { + expect(b).toEqual('b'); + }); + + // or + userEvent.click(button); + await waitFor(() => { + expect(b).toEqual('b'); + }); + + // or + userEvent.click(button); + await waitFor(function() { + expect(b).toEqual('b'); + }); +}; +``` + +## Further Reading + +- [about `waitFor`](https://testing-library.com/docs/dom-testing-library/api-async#waitfor) +- [about `userEvent`](https://github.com/testing-library/user-event) +- [about `fireEvent`](https://testing-library.com/docs/dom-testing-library/api-events) +- [inspiration for this rule](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#performing-side-effects-in-waitfor) diff --git a/docs/rules/no-wait-for-snapshot.md b/docs/rules/no-wait-for-snapshot.md index 8f35f2ab..65b3683f 100644 --- a/docs/rules/no-wait-for-snapshot.md +++ b/docs/rules/no-wait-for-snapshot.md @@ -1,13 +1,13 @@ -# Ensures no snapshot is generated inside of a `wait` call' (no-wait-for-snapshot) +# Ensures no snapshot is generated inside a `waitFor` call (`testing-library/no-wait-for-snapshot`) Ensure that no calls to `toMatchSnapshot` or `toMatchInlineSnapshot` are made from within a `waitFor` method (or any of the other async utility methods). ## Rule Details The `waitFor()` method runs in a timer loop. So it'll retry every n amount of time. -If a snapshot is generated inside the wait condition, jest will generate one snapshot per loop. +If a snapshot is generated inside the wait condition, jest will generate one snapshot per each loop. -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 who'll fail during CI. +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. 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). diff --git a/docs/rules/prefer-explicit-assert.md b/docs/rules/prefer-explicit-assert.md index b52a17e8..03338517 100644 --- a/docs/rules/prefer-explicit-assert.md +++ b/docs/rules/prefer-explicit-assert.md @@ -1,4 +1,4 @@ -# Suggest using explicit assertions rather than just `getBy*` queries (prefer-explicit-assert) +# Suggest using explicit assertions rather than just `getBy*` queries (`testing-library/prefer-explicit-assert`) Testing Library `getBy*` queries throw an error if the element is not found. Some users like this behavior to use the query itself as an @@ -43,14 +43,11 @@ expect(queryByText('foo')).toBeInTheDocument(); await waitForElement(() => getByText('foo')); fireEvent.click(getByText('bar')); const quxElement = getByText('qux'); - -// call directly something different than Testing Library query -getByNonTestingLibraryVariant('foo'); ``` ## Options -This rule has a few options: +This rule has one option: - `assertion`: this string allows defining the preferred assertion to use with `getBy*` queries. By default, any assertion is valid (`toBeTruthy`, @@ -66,18 +63,9 @@ This rule has a few options: "testing-library/prefer-explicit-assert": ["error", {"assertion": "toBeInTheDocument"}], ``` -- `customQueryNames`: this array option allows to extend default Testing - Library queries with custom ones for including them into rule - inspection. - - ```js - "testing-library/prefer-explicit-assert": ["error", {"customQueryNames": ["getByIcon", "getBySomethingElse"]}], - ``` - ## When Not To Use It -If you prefer to use `getBy*` queries implicitly as an assert-like -method itself, then this rule is not recommended. +If you prefer to use `getBy*` queries implicitly as an assert-like method itself, then this rule is not recommended. ## Further Reading diff --git a/docs/rules/prefer-find-by.md b/docs/rules/prefer-find-by.md index e24d585b..d85b296f 100644 --- a/docs/rules/prefer-find-by.md +++ b/docs/rules/prefer-find-by.md @@ -1,11 +1,11 @@ -# Suggest using `findBy*` methods instead of the `waitFor` + `getBy` queries (prefer-find-by) +# Suggest using `findBy*` methods instead of the `waitFor` + `getBy` queries (`testing-library/prefer-find-by`) 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)) ## Rule details -This rule aims to use `findBy*` or `findAllBy*` queries to wait for elements, rather than using `waitFor`, or the deprecated methods `waitForElement` and `wait`. -This rules 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 +This rule aims to use `findBy*` or `findAllBy*` queries to wait for elements, rather than using `waitFor`, or the deprecated methods `waitForElement` and `wait`. +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 Examples of **incorrect** code for this rule @@ -41,7 +41,7 @@ await waitForElementToBeRemoved(() => queryAllByLabel('my label')); await waitForElementToBeRemoved(document.querySelector('foo')); // using waitFor with a function -await waitFor(function() { +await waitFor(function () { foo(); return getByText('name'); }); diff --git a/docs/rules/prefer-presence-queries.md b/docs/rules/prefer-presence-queries.md index f66dff91..05ef773a 100644 --- a/docs/rules/prefer-presence-queries.md +++ b/docs/rules/prefer-presence-queries.md @@ -1,4 +1,4 @@ -# Enforce specific queries when checking element is present or not (prefer-presence-queries) +# Enforce specific queries when checking element is present or not (`testing-library/prefer-presence-queries`) 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). These differences are useful in some situations: diff --git a/docs/rules/prefer-screen-queries.md b/docs/rules/prefer-screen-queries.md index 79978017..081d7283 100644 --- a/docs/rules/prefer-screen-queries.md +++ b/docs/rules/prefer-screen-queries.md @@ -1,4 +1,4 @@ -# Suggest using screen while using queries (prefer-screen-queries) +# Suggest using `screen` while using queries (`testing-library/prefer-screen-queries`) ## Rule Details diff --git a/docs/rules/prefer-user-event.md b/docs/rules/prefer-user-event.md new file mode 100644 index 00000000..dda1aca0 --- /dev/null +++ b/docs/rules/prefer-user-event.md @@ -0,0 +1,130 @@ +# Suggest using `userEvent` library instead of `fireEvent` for simulating user interaction (`testing-library/prefer-user-event`) + +From +[testing-library/dom-testing-library#107](https://github.com/testing-library/dom-testing-library/issues/107): + +> [...] it is becoming apparent the need to express user actions on a web page +> using a higher-level abstraction than `fireEvent` + +`userEvent` adds related event calls from browsers to make tests more realistic than its counterpart `fireEvent`, which is a low-level api. +See the appendix at the end to check how are the events from `fireEvent` mapped to `userEvent`. + +## Rule Details + +This rule enforces the usage of [userEvent](https://github.com/testing-library/user-event) methods over `fireEvent`. By default, the methods from `userEvent` take precedence, but you add exceptions by configuring the rule in `.eslintrc`. + +Examples of **incorrect** code for this rule: + +```ts +// a method in fireEvent that has a userEvent equivalent +import { fireEvent } from '@testing-library/dom'; +// or const { fireEvent } = require('@testing-library/dom'); +fireEvent.click(node); + +// using fireEvent with an alias +import { fireEvent as fireEventAliased } from '@testing-library/dom'; +fireEventAliased.click(node); + +// using fireEvent after importing the entire library +import * as dom from '@testing-library/dom'; +// or const dom = require(@testing-library/dom'); +dom.fireEvent.click(node); +``` + +Examples of **correct** code for this rule: + +```ts +import userEvent from '@testing-library/user-event'; +// or const userEvent = require('@testing-library/user-event'); + +// any userEvent method +userEvent.click(); + +// fireEvent method that does not have an alternative in userEvent +import { fireEvent } from '@testing-library/dom'; +// or const { fireEvent } = require('@testing-library/dom'); +fireEvent.cut(node); + +import * as dom from '@testing-library/dom'; +// or const dom = require('@testing-library/dom'); +dom.fireEvent.cut(node); +``` + +#### Options + +This rule allows to exclude specific functions with an equivalent in `userEvent` through configuration. This is useful if you need to allow an event from `fireEvent` to be used in the solution. For specific scenarios, you might want to consider disabling the rule inline. + +The configuration consists of an array of strings with the names of fireEvents methods to be excluded. +An example looks like this + +```json +{ + "rules": { + "prefer-user-event": [ + "error", + { + "allowedMethods": ["click", "change"] + } + ] + } +} +``` + +With this configuration example, the following use cases are considered valid + +```ts +// using a named import +import { fireEvent } from '@testing-library/dom'; +// or const { fireEvent } = require('@testing-library/dom'); +fireEvent.click(node); +fireEvent.change(node, { target: { value: 'foo' } }); + +// using fireEvent with an alias +import { fireEvent as fireEventAliased } from '@testing-library/dom'; +fireEventAliased.click(node); +fireEventAliased.change(node, { target: { value: 'foo' } }); + +// using fireEvent after importing the entire library +import * as dom from '@testing-library/dom'; +// or const dom = require('@testing-library/dom'); +dom.fireEvent.click(node); +dom.fireEvent.change(node, { target: { value: 'foo' } }); +``` + +## When Not To Use It + +When you don't want to use `userEvent`, such as if a legacy codebase is still using `fireEvent` or you need to have more low-level control over firing events (rather than the recommended approach of testing from a user's perspective) + +## Further Reading + +- [`user-event` repository](https://github.com/testing-library/user-event) +- [`userEvent` in the Testing Library docs](https://testing-library.com/docs/ecosystem-user-event) + +## Appendix + +The following table lists all the possible equivalents from the low-level API `fireEvent` to the higher abstraction API `userEvent`. All the events not listed here do not have an equivalent (yet) + +| fireEvent method | Possible options in userEvent | +| ---------------- | ----------------------------------------------------------------------------------------------------------- | +| `click` |
  • `click`
  • `type`
  • `selectOptions`
  • `deselectOptions`
| +| `change` |
  • `upload`
  • `type`
  • `clear`
  • `selectOptions`
  • `deselectOptions`
| +| `dblClick` |
  • `dblClick`
| +| `input` |
  • `type`
  • `upload`
  • `selectOptions`
  • `deselectOptions`
  • `paste`
| +| `keyDown` |
  • `type`
  • `tab`
| +| `keyPress` |
  • `type`
| +| `keyUp` |
  • `type`
  • `tab`
| +| `mouseDown` |
  • `click`
  • `dblClick`
  • `selectOptions`
  • `deselectOptions`
| +| `mouseEnter` |
  • `hover`
  • `selectOptions`
  • `deselectOptions`
| +| `mouseLeave` |
  • `unhover`
| +| `mouseMove` |
  • `hover`
  • `unhover`
  • `selectOptions`
  • `deselectOptions`
| +| `mouseOut` |
  • `unhover`
| +| `mouseOver` |
  • `hover`
  • `selectOptions`
  • `deselectOptions`
| +| `mouseUp` |
  • `click`
  • `dblClick`
  • `selectOptions`
  • `deselectOptions`
| +| `paste` |
  • `paste`
| +| `pointerDown` |
  • `click`
  • `dblClick`
  • `selectOptions`
  • `deselectOptions`
| +| `pointerEnter` |
  • `hover`
  • `selectOptions`
  • `deselectOptions`
| +| `pointerLeave` |
  • `unhover`
| +| `pointerMove` |
  • `hover`
  • `unhover`
  • `selectOptions`
  • `deselectOptions`
| +| `pointerOut` |
  • `unhover`
| +| `pointerOver` |
  • `hover`
  • `selectOptions`
  • `deselectOptions`
| +| `pointerUp` |
  • `click`
  • `dblClick`
  • `selectOptions`
  • `deselectOptions`
| diff --git a/docs/rules/prefer-wait-for.md b/docs/rules/prefer-wait-for.md index ac32c7a5..ee82769c 100644 --- a/docs/rules/prefer-wait-for.md +++ b/docs/rules/prefer-wait-for.md @@ -1,4 +1,4 @@ -# Use `waitFor` instead of deprecated wait methods (prefer-wait-for) +# Use `waitFor` instead of deprecated wait methods (`testing-library/prefer-wait-for`) `dom-testing-library` v7 released a new async util called `waitFor` which satisfies the use cases of `wait`, `waitForElement`, and `waitForDomChange` making them deprecated. @@ -17,6 +17,9 @@ Deprecated `wait` async utils are: Examples of **incorrect** code for this rule: ```js +import { wait, waitForElement, waitForDomChange } from '@testing-library/dom'; +// this also works for const { wait, waitForElement, waitForDomChange } = require ('@testing-library/dom') + const foo = async () => { await wait(); await wait(() => {}); @@ -25,11 +28,24 @@ const foo = async () => { await waitForDomChange(mutationObserverOptions); await waitForDomChange({ timeout: 100 }); }; + +import * as tl from '@testing-library/dom'; +// this also works for const tl = require('@testing-library/dom') +const foo = async () => { + await tl.wait(); + await tl.wait(() => {}); + await tl.waitForElement(() => {}); + await tl.waitForDomChange(); + await tl.waitForDomChange(mutationObserverOptions); + await tl.waitForDomChange({ timeout: 100 }); +}; ``` Examples of **correct** code for this rule: ```js +import { waitFor, waitForElementToBeRemoved } from '@testing-library/dom'; +// this also works for const { waitFor, waitForElementToBeRemoved } = require('@testing-library/dom') const foo = async () => { // new waitFor method await waitFor(() => {}); @@ -37,6 +53,16 @@ const foo = async () => { // previous waitForElementToBeRemoved is not deprecated await waitForElementToBeRemoved(() => {}); }; + +import * as tl from '@testing-library/dom'; +// this also works for const tl = require('@testing-library/dom') +const foo = async () => { + // new waitFor method + await tl.waitFor(() => {}); + + // previous waitForElementToBeRemoved is not deprecated + await tl.waitForElementToBeRemoved(() => {}); +}; ``` ## When Not To Use It diff --git a/docs/rules/render-result-naming-convention.md b/docs/rules/render-result-naming-convention.md new file mode 100644 index 00000000..f9e2e4fe --- /dev/null +++ b/docs/rules/render-result-naming-convention.md @@ -0,0 +1,78 @@ +# Enforce a valid naming for return value from `render` (`testing-library/render-result-naming-convention`) + +> 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. + +## Rule Details + +This rule aims to ensure the return value from `render` is named properly. + +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. + +To sum up these rules, the allowed naming convention for return value from `render` is: + +- destructuring +- `view` +- `utils` + +Examples of **incorrect** code for this rule: + +```javascript +import { render } from '@testing-library/framework'; + +// ... + +// return value from `render` shouldn't be kept in a var called "wrapper" +const wrapper = render(); +``` + +```javascript +import { render } from '@testing-library/framework'; + +// ... + +// return value from `render` shouldn't be kept in a var called "component" +const component = render(); +``` + +```javascript +import { render } from '@testing-library/framework'; + +// ... + +// to sum up: return value from `render` shouldn't be kept in a var called other than "view" or "utils" +const somethingElse = render(); +``` + +Examples of **correct** code for this rule: + +```javascript +import { render } from '@testing-library/framework'; + +// ... + +// destructuring return value from `render` is correct +const { unmount, rerender } = render(); +``` + +```javascript +import { render } from '@testing-library/framework'; + +// ... + +// keeping return value from `render` in a var called "view" is correct +const view = render(); +``` + +```javascript +import { render } from '@testing-library/framework'; + +// ... + +// keeping return value from `render` in a var called "utils" is correct +const utils = render(); +``` + +## Further Reading + +- [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) +- [`render` Result](https://testing-library.com/docs/react-testing-library/api#render-result) diff --git a/jest.config.js b/jest.config.js index d9dd44ea..650de922 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,21 +1,15 @@ module.exports = { + testEnvironment: 'jest-environment-jsdom', testMatch: ['**/tests/**/*.test.ts'], transform: { '^.+\\.tsx?$': 'ts-jest', }, coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, - }, - // TODO drop this custom threshold in v4 - "./lib/node-utils.ts": { branches: 90, functions: 90, lines: 90, statements: 90, - } + }, }, }; diff --git a/lib/create-testing-library-rule.ts b/lib/create-testing-library-rule.ts new file mode 100644 index 00000000..96f47d66 --- /dev/null +++ b/lib/create-testing-library-rule.ts @@ -0,0 +1,34 @@ +import { ESLintUtils, TSESLint } from '@typescript-eslint/experimental-utils'; +import { getDocsUrl } from './utils'; +import { + detectTestingLibraryUtils, + EnhancedRuleCreate, +} from './detect-testing-library-utils'; + +// These 2 types are copied from @typescript-eslint/experimental-utils +type CreateRuleMetaDocs = Omit; +type CreateRuleMeta = { + docs: CreateRuleMetaDocs; +} & Omit, 'docs'>; + +export function createTestingLibraryRule< + TOptions extends readonly unknown[], + TMessageIds extends string, + TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener +>( + config: Readonly<{ + name: string; + meta: CreateRuleMeta; + defaultOptions: Readonly; + create: EnhancedRuleCreate; + }> +): TSESLint.RuleModule { + const { create, ...remainingConfig } = config; + + return ESLintUtils.RuleCreator(getDocsUrl)({ + ...remainingConfig, + create: detectTestingLibraryUtils( + create + ), + }); +} diff --git a/lib/detect-testing-library-utils.ts b/lib/detect-testing-library-utils.ts new file mode 100644 index 00000000..33ca3350 --- /dev/null +++ b/lib/detect-testing-library-utils.ts @@ -0,0 +1,827 @@ +import { + ASTUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import { + getAssertNodeInfo, + getDeepestIdentifierNode, + getImportModuleName, + getPropertyIdentifierNode, + getReferenceNode, + hasImportMatch, + ImportModuleNode, + isImportDeclaration, + isImportDefaultSpecifier, + isImportNamespaceSpecifier, + isImportSpecifier, + isLiteral, + isMemberExpression, + isObjectPattern, + isProperty, +} from './node-utils'; +import { + ABSENCE_MATCHERS, + ALL_QUERIES_COMBINATIONS, + ASYNC_UTILS, + PRESENCE_MATCHERS, +} from './utils'; + +export type TestingLibrarySettings = { + 'testing-library/utils-module'?: string; + 'testing-library/custom-renders'?: string[]; +}; + +export type TestingLibraryContext< + TOptions extends readonly unknown[], + TMessageIds extends string +> = Readonly< + TSESLint.RuleContext & { + settings: TestingLibrarySettings; + } +>; + +export type EnhancedRuleCreate< + TOptions extends readonly unknown[], + TMessageIds extends string, + TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener +> = ( + context: TestingLibraryContext, + optionsWithDefault: Readonly, + detectionHelpers: Readonly +) => TRuleListener; + +// Helpers methods +type GetTestingLibraryImportNodeFn = () => ImportModuleNode | null; +type GetCustomModuleImportNodeFn = () => ImportModuleNode | null; +type GetTestingLibraryImportNameFn = () => string | undefined; +type GetCustomModuleImportNameFn = () => string | undefined; +type IsTestingLibraryImportedFn = () => boolean; +type IsGetQueryVariantFn = (node: TSESTree.Identifier) => boolean; +type IsQueryQueryVariantFn = (node: TSESTree.Identifier) => boolean; +type IsFindQueryVariantFn = (node: TSESTree.Identifier) => boolean; +type IsSyncQueryFn = (node: TSESTree.Identifier) => boolean; +type IsAsyncQueryFn = (node: TSESTree.Identifier) => boolean; +type IsQueryFn = (node: TSESTree.Identifier) => boolean; +type IsCustomQueryFn = (node: TSESTree.Identifier) => boolean; +type IsAsyncUtilFn = ( + node: TSESTree.Identifier, + validNames?: readonly typeof ASYNC_UTILS[number][] +) => boolean; +type IsFireEventMethodFn = (node: TSESTree.Identifier) => boolean; +type IsUserEventMethodFn = (node: TSESTree.Identifier) => boolean; +type IsRenderUtilFn = (node: TSESTree.Identifier) => boolean; +type IsRenderVariableDeclaratorFn = ( + node: TSESTree.VariableDeclarator +) => boolean; +type IsDebugUtilFn = (node: TSESTree.Identifier) => boolean; +type IsPresenceAssertFn = (node: TSESTree.MemberExpression) => boolean; +type IsAbsenceAssertFn = (node: TSESTree.MemberExpression) => boolean; +type CanReportErrorsFn = () => boolean; +type FindImportedUtilSpecifierFn = ( + specifierName: string +) => TSESTree.ImportClause | TSESTree.Identifier | undefined; +type IsNodeComingFromTestingLibraryFn = ( + node: TSESTree.MemberExpression | TSESTree.Identifier +) => boolean; + +export interface DetectionHelpers { + getTestingLibraryImportNode: GetTestingLibraryImportNodeFn; + getCustomModuleImportNode: GetCustomModuleImportNodeFn; + getTestingLibraryImportName: GetTestingLibraryImportNameFn; + getCustomModuleImportName: GetCustomModuleImportNameFn; + isTestingLibraryImported: IsTestingLibraryImportedFn; + isGetQueryVariant: IsGetQueryVariantFn; + isQueryQueryVariant: IsQueryQueryVariantFn; + isFindQueryVariant: IsFindQueryVariantFn; + isSyncQuery: IsSyncQueryFn; + isAsyncQuery: IsAsyncQueryFn; + isQuery: IsQueryFn; + isCustomQuery: IsCustomQueryFn; + isAsyncUtil: IsAsyncUtilFn; + isFireEventUtil: (node: TSESTree.Identifier) => boolean; + isUserEventUtil: (node: TSESTree.Identifier) => boolean; + isFireEventMethod: IsFireEventMethodFn; + isUserEventMethod: IsUserEventMethodFn; + isRenderUtil: IsRenderUtilFn; + isRenderVariableDeclarator: IsRenderVariableDeclaratorFn; + isDebugUtil: IsDebugUtilFn; + isPresenceAssert: IsPresenceAssertFn; + isAbsenceAssert: IsAbsenceAssertFn; + canReportErrors: CanReportErrorsFn; + findImportedUtilSpecifier: FindImportedUtilSpecifierFn; + isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn; +} + +const USER_EVENT_PACKAGE = '@testing-library/user-event'; +const FIRE_EVENT_NAME = 'fireEvent'; +const USER_EVENT_NAME = 'userEvent'; +const RENDER_NAME = 'render'; + +/** + * Enhances a given rule `create` with helpers to detect Testing Library utils. + */ +export function detectTestingLibraryUtils< + TOptions extends readonly unknown[], + TMessageIds extends string, + TRuleListener extends TSESLint.RuleListener = TSESLint.RuleListener +>(ruleCreate: EnhancedRuleCreate) { + return ( + context: TestingLibraryContext, + optionsWithDefault: Readonly + ): TSESLint.RuleListener => { + let importedTestingLibraryNode: ImportModuleNode | null = null; + let importedCustomModuleNode: ImportModuleNode | null = null; + let importedUserEventLibraryNode: ImportModuleNode | null = null; + + // Init options based on shared ESLint settings + const customModule = context.settings['testing-library/utils-module']; + const customRenders = + context.settings['testing-library/custom-renders'] ?? []; + + /** + * Small method to extract common checks to determine whether a node is + * related to Testing Library or not. + * + * To determine whether a node is a valid Testing Library util, there are + * two conditions to match: + * - it's named in a particular way (decided by given callback) + * - it's imported from valid Testing Library module (depends on aggressive + * reporting) + */ + function isTestingLibraryUtil( + node: TSESTree.Identifier, + isUtilCallback: ( + identifierNodeName: string, + originalNodeName?: string + ) => boolean + ): boolean { + if (!node) { + return false; + } + + const referenceNode = getReferenceNode(node); + const referenceNodeIdentifier = getPropertyIdentifierNode(referenceNode); + + if (!referenceNodeIdentifier) { + return false; + } + + const importedUtilSpecifier = getImportedUtilSpecifier( + referenceNodeIdentifier + ); + + const originalNodeName = + isImportSpecifier(importedUtilSpecifier) && + importedUtilSpecifier.local.name !== importedUtilSpecifier.imported.name + ? importedUtilSpecifier.imported.name + : undefined; + + if (!isUtilCallback(node.name, originalNodeName)) { + return false; + } + + if (isAggressiveModuleReportingEnabled()) { + return true; + } + + return isNodeComingFromTestingLibrary(referenceNodeIdentifier); + } + + /** + * Determines whether aggressive module reporting is enabled or not. + * + * This aggressive reporting mechanism is considered as enabled when custom + * module is not set, so we need to assume everything matching Testing + * Library utils is related to Testing Library no matter from where module + * they are coming from. Otherwise, this aggressive reporting mechanism is + * opted-out in favour to report only those utils coming from Testing + * Library package or custom module set up on settings. + */ + const isAggressiveModuleReportingEnabled = () => !customModule; + + /** + * Determines whether aggressive render reporting is enabled or not. + * + * This aggressive reporting mechanism is considered as enabled when custom + * renders are not set, so we need to assume every method containing + * "render" is a valid Testing Library `render`. Otherwise, this aggressive + * reporting mechanism is opted-out in favour to report only `render` or + * names set up on custom renders setting. + */ + const isAggressiveRenderReportingEnabled = () => + !Array.isArray(customRenders) || customRenders.length === 0; + + // Helpers for Testing Library detection. + const getTestingLibraryImportNode: GetTestingLibraryImportNodeFn = () => { + return importedTestingLibraryNode; + }; + + const getCustomModuleImportNode: GetCustomModuleImportNodeFn = () => { + return importedCustomModuleNode; + }; + + const getTestingLibraryImportName: GetTestingLibraryImportNameFn = () => { + return getImportModuleName(importedTestingLibraryNode); + }; + + const getCustomModuleImportName: GetCustomModuleImportNameFn = () => { + return getImportModuleName(importedCustomModuleNode); + }; + + /** + * Determines whether Testing Library utils are imported or not for + * current file being analyzed. + * + * By default, it is ALWAYS considered as imported. This is what we call + * "aggressive reporting" so we don't miss TL utils reexported from + * custom modules. + * + * However, there is a setting to customize the module where TL utils can + * be imported from: "testing-library/utils-module". If this setting is enabled, + * then this method will return `true` ONLY IF a testing-library package + * or custom module are imported. + */ + const isTestingLibraryImported: IsTestingLibraryImportedFn = () => { + return ( + isAggressiveModuleReportingEnabled() || + !!importedTestingLibraryNode || + !!importedCustomModuleNode + ); + }; + + /** + * Determines whether a given node is `get*` query variant or not. + */ + const isGetQueryVariant: IsGetQueryVariantFn = (node) => { + return /^get(All)?By.+$/.test(node.name); + }; + + /** + * Determines whether a given node is `query*` query variant or not. + */ + const isQueryQueryVariant: IsQueryQueryVariantFn = (node) => { + return /^query(All)?By.+$/.test(node.name); + }; + + /** + * Determines whether a given node is `find*` query variant or not. + */ + const isFindQueryVariant: IsFindQueryVariantFn = (node) => { + return /^find(All)?By.+$/.test(node.name); + }; + + /** + * Determines whether a given node is sync query or not. + */ + const isSyncQuery: IsSyncQueryFn = (node) => { + return isGetQueryVariant(node) || isQueryQueryVariant(node); + }; + + /** + * Determines whether a given node is async query or not. + */ + const isAsyncQuery: IsAsyncQueryFn = (node) => { + return isFindQueryVariant(node); + }; + + /** + * Determines whether a given node is a valid query, + * either built-in or custom + */ + const isQuery: IsQueryFn = (node) => { + return isSyncQuery(node) || isAsyncQuery(node); + }; + + const isCustomQuery: IsCustomQueryFn = (node) => { + return isQuery(node) && !ALL_QUERIES_COMBINATIONS.includes(node.name); + }; + + /** + * Determines whether a given node is a valid async util or not. + * + * A node will be interpreted as a valid async util based on two conditions: + * the name matches with some Testing Library async util, and the node is + * coming from Testing Library module. + * + * The latter depends on Aggressive module reporting: + * if enabled, then it doesn't matter from where the given node was imported + * from as it will be considered part of Testing Library. + * Otherwise, it means `custom-module` has been set up, so only those nodes + * coming from Testing Library will be considered as valid. + */ + const isAsyncUtil: IsAsyncUtilFn = (node, validNames = ASYNC_UTILS) => { + return isTestingLibraryUtil( + node, + (identifierNodeName, originalNodeName) => { + return ( + (validNames as string[]).includes(identifierNodeName) || + (!!originalNodeName && + (validNames as string[]).includes(originalNodeName)) + ); + } + ); + }; + + /** + * Determines whether a given node is fireEvent util itself or not. + * + * Not to be confused with {@link isFireEventMethod} + */ + const isFireEventUtil = (node: TSESTree.Identifier): boolean => { + return isTestingLibraryUtil( + node, + (identifierNodeName, originalNodeName) => { + return [identifierNodeName, originalNodeName].includes('fireEvent'); + } + ); + }; + + /** + * Determines whether a given node is userEvent util itself or not. + * + * Not to be confused with {@link isUserEventMethod} + */ + const isUserEventUtil = (node: TSESTree.Identifier): boolean => { + const userEvent = findImportedUserEventSpecifier(); + let userEventName: string | undefined; + + if (userEvent) { + userEventName = userEvent.name; + } else if (isAggressiveModuleReportingEnabled()) { + userEventName = USER_EVENT_NAME; + } + + if (!userEventName) { + return false; + } + + return node.name === userEventName; + }; + + /** + * Determines whether a given node is fireEvent method or not + */ + const isFireEventMethod: IsFireEventMethodFn = (node) => { + const fireEventUtil = findImportedUtilSpecifier(FIRE_EVENT_NAME); + let fireEventUtilName: string | undefined; + + if (fireEventUtil) { + fireEventUtilName = ASTUtils.isIdentifier(fireEventUtil) + ? fireEventUtil.name + : fireEventUtil.local.name; + } else if (isAggressiveModuleReportingEnabled()) { + fireEventUtilName = FIRE_EVENT_NAME; + } + + if (!fireEventUtilName) { + return false; + } + + const parentMemberExpression: TSESTree.MemberExpression | undefined = + node.parent && isMemberExpression(node.parent) + ? node.parent + : undefined; + + if (!parentMemberExpression) { + return false; + } + + // make sure that given node it's not fireEvent object itself + if ( + [fireEventUtilName, FIRE_EVENT_NAME].includes(node.name) || + (ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === node.name) + ) { + return false; + } + + // check fireEvent.click() usage + const regularCall = + ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === fireEventUtilName; + + // check testingLibraryUtils.fireEvent.click() usage + const wildcardCall = + isMemberExpression(parentMemberExpression.object) && + ASTUtils.isIdentifier(parentMemberExpression.object.object) && + parentMemberExpression.object.object.name === fireEventUtilName && + ASTUtils.isIdentifier(parentMemberExpression.object.property) && + parentMemberExpression.object.property.name === FIRE_EVENT_NAME; + + return regularCall || wildcardCall; + }; + + const isUserEventMethod: IsUserEventMethodFn = (node) => { + const userEvent = findImportedUserEventSpecifier(); + let userEventName: string | undefined; + + if (userEvent) { + userEventName = userEvent.name; + } else if (isAggressiveModuleReportingEnabled()) { + userEventName = USER_EVENT_NAME; + } + + if (!userEventName) { + return false; + } + + const parentMemberExpression: TSESTree.MemberExpression | undefined = + node.parent && isMemberExpression(node.parent) + ? node.parent + : undefined; + + if (!parentMemberExpression) { + return false; + } + + // make sure that given node it's not userEvent object itself + if ( + [userEventName, USER_EVENT_NAME].includes(node.name) || + (ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === node.name) + ) { + return false; + } + + // check userEvent.click() usage + return ( + ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === userEventName + ); + }; + + /** + * Determines whether a given node is a valid render util or not. + * + * A node will be interpreted as a valid render based on two conditions: + * the name matches with a valid "render" option, and the node is coming + * from Testing Library module. This depends on: + * + * - Aggressive render reporting: if enabled, then every node name + * containing "render" will be assumed as Testing Library render util. + * Otherwise, it means `custom-modules` has been set up, so only those nodes + * named as "render" or some of the `custom-modules` options will be + * considered as Testing Library render util. + * - Aggressive module reporting: if enabled, then it doesn't matter from + * where the given node was imported from as it will be considered part of + * Testing Library. Otherwise, it means `custom-module` has been set up, so + * only those nodes coming from Testing Library will be considered as valid. + */ + const isRenderUtil: IsRenderUtilFn = (node) => { + return isTestingLibraryUtil( + node, + (identifierNodeName, originalNodeName) => { + if (isAggressiveRenderReportingEnabled()) { + return identifierNodeName.toLowerCase().includes(RENDER_NAME); + } + + return [RENDER_NAME, ...customRenders].some((validRenderName) => { + let isMatch = false; + + if (validRenderName === identifierNodeName) { + isMatch = true; + } + + if (!!originalNodeName && validRenderName === originalNodeName) { + isMatch = true; + } + return isMatch; + }); + } + ); + }; + + const isRenderVariableDeclarator: IsRenderVariableDeclaratorFn = (node) => { + if (!node.init) { + return false; + } + const initIdentifierNode = getDeepestIdentifierNode(node.init); + + if (!initIdentifierNode) { + return false; + } + + return isRenderUtil(initIdentifierNode); + }; + + const isDebugUtil: IsDebugUtilFn = (node) => { + return isTestingLibraryUtil( + node, + (identifierNodeName, originalNodeName) => { + return [identifierNodeName, originalNodeName] + .filter(Boolean) + .includes('debug'); + } + ); + }; + + /** + * Determines whether a given MemberExpression node is a presence assert + * + * Presence asserts could have shape of: + * - expect(element).toBeInTheDocument() + * - expect(element).not.toBeNull() + */ + const isPresenceAssert: IsPresenceAssertFn = (node) => { + const { matcher, isNegated } = getAssertNodeInfo(node); + + if (!matcher) { + return false; + } + + return isNegated + ? ABSENCE_MATCHERS.includes(matcher) + : PRESENCE_MATCHERS.includes(matcher); + }; + + /** + * Determines whether a given MemberExpression node is an absence assert + * + * Absence asserts could have shape of: + * - expect(element).toBeNull() + * - expect(element).not.toBeInTheDocument() + */ + const isAbsenceAssert: IsAbsenceAssertFn = (node) => { + const { matcher, isNegated } = getAssertNodeInfo(node); + + if (!matcher) { + return false; + } + + return isNegated + ? PRESENCE_MATCHERS.includes(matcher) + : ABSENCE_MATCHERS.includes(matcher); + }; + + /** + * Gets a string and verifies if it was imported/required by Testing Library + * related module. + */ + const findImportedUtilSpecifier: FindImportedUtilSpecifierFn = ( + specifierName + ) => { + const node = getCustomModuleImportNode() ?? getTestingLibraryImportNode(); + + if (!node) { + return undefined; + } + + if (isImportDeclaration(node)) { + const namedExport = node.specifiers.find((n) => { + return ( + isImportSpecifier(n) && + [n.imported.name, n.local.name].includes(specifierName) + ); + }); + + // it is "import { foo [as alias] } from 'baz'"" + if (namedExport) { + return namedExport; + } + + // it could be "import * as rtl from 'baz'" + return node.specifiers.find((n) => isImportNamespaceSpecifier(n)); + } else { + if (!ASTUtils.isVariableDeclarator(node.parent)) { + return undefined; + } + const requireNode = node.parent; + + if (ASTUtils.isIdentifier(requireNode.id)) { + // this is const rtl = require('foo') + return requireNode.id; + } + + // this should be const { something } = require('foo') + if (!isObjectPattern(requireNode.id)) { + return undefined; + } + + const property = requireNode.id.properties.find( + (n) => + isProperty(n) && + ASTUtils.isIdentifier(n.key) && + n.key.name === specifierName + ); + if (!property) { + return undefined; + } + return (property as TSESTree.Property).key as TSESTree.Identifier; + } + }; + + const findImportedUserEventSpecifier: () => TSESTree.Identifier | null = () => { + if (!importedUserEventLibraryNode) { + return null; + } + + if (isImportDeclaration(importedUserEventLibraryNode)) { + const userEventIdentifier = importedUserEventLibraryNode.specifiers.find( + (specifier) => isImportDefaultSpecifier(specifier) + ); + + if (userEventIdentifier) { + return userEventIdentifier.local; + } + } else { + if ( + !ASTUtils.isVariableDeclarator(importedUserEventLibraryNode.parent) + ) { + return null; + } + + const requireNode = importedUserEventLibraryNode.parent; + if (!ASTUtils.isIdentifier(requireNode.id)) { + return null; + } + + return requireNode.id; + } + + return null; + }; + + const getImportedUtilSpecifier = ( + node: TSESTree.MemberExpression | TSESTree.Identifier + ): TSESTree.ImportClause | TSESTree.Identifier | undefined => { + const identifierName: string | undefined = getPropertyIdentifierNode(node) + ?.name; + + if (!identifierName) { + return undefined; + } + + return findImportedUtilSpecifier(identifierName); + }; + + /** + * Determines if file inspected meets all conditions to be reported by rules or not. + */ + const canReportErrors: CanReportErrorsFn = () => { + return isTestingLibraryImported(); + }; + + /** + * Determines whether a node is imported from a valid Testing Library module + * + * This method will try to find any import matching the given node name, + * and also make sure the name is a valid match in case it's been renamed. + */ + const isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn = ( + node + ) => { + const importNode = getImportedUtilSpecifier(node); + + if (!importNode) { + return false; + } + + const identifierName: string | undefined = getPropertyIdentifierNode(node) + ?.name; + + if (!identifierName) { + return false; + } + + return hasImportMatch(importNode, identifierName); + }; + + const helpers: DetectionHelpers = { + getTestingLibraryImportNode, + getCustomModuleImportNode, + getTestingLibraryImportName, + getCustomModuleImportName, + isTestingLibraryImported, + isGetQueryVariant, + isQueryQueryVariant, + isFindQueryVariant, + isSyncQuery, + isAsyncQuery, + isQuery, + isCustomQuery, + isAsyncUtil, + isFireEventUtil, + isUserEventUtil, + isFireEventMethod, + isUserEventMethod, + isRenderUtil, + isRenderVariableDeclarator, + isDebugUtil, + isPresenceAssert, + isAbsenceAssert, + canReportErrors, + findImportedUtilSpecifier, + isNodeComingFromTestingLibrary, + }; + + // Instructions for Testing Library detection. + const detectionInstructions: TSESLint.RuleListener = { + /** + * This ImportDeclaration rule listener will check if Testing Library related + * modules are imported. Since imports happen first thing in a file, it's + * safe to use `isImportingTestingLibraryModule` and `isImportingCustomModule` + * since they will have corresponding value already updated when reporting other + * parts of the file. + */ + ImportDeclaration(node: TSESTree.ImportDeclaration) { + // check only if testing library import not found yet so we avoid + // to override importedTestingLibraryNode after it's found + if ( + !importedTestingLibraryNode && + /testing-library/g.test(node.source.value as string) + ) { + importedTestingLibraryNode = node; + } + + // check only if custom module import not found yet so we avoid + // to override importedCustomModuleNode after it's found + if ( + customModule && + !importedCustomModuleNode && + String(node.source.value).endsWith(customModule) + ) { + importedCustomModuleNode = node; + } + + // check only if user-event import not found yet so we avoid + // to override importedUserEventLibraryNode after it's found + if ( + !importedUserEventLibraryNode && + String(node.source.value) === USER_EVENT_PACKAGE + ) { + importedUserEventLibraryNode = node; + } + }, + + // Check if Testing Library related modules are loaded with required. + [`CallExpression > Identifier[name="require"]`]( + node: TSESTree.Identifier + ) { + const callExpression = node.parent as TSESTree.CallExpression; + const { arguments: args } = callExpression; + + if ( + !importedTestingLibraryNode && + args.some( + (arg) => + isLiteral(arg) && + typeof arg.value === 'string' && + /testing-library/g.test(arg.value) + ) + ) { + importedTestingLibraryNode = callExpression; + } + + if ( + !importedCustomModuleNode && + args.some( + (arg) => + customModule && + isLiteral(arg) && + typeof arg.value === 'string' && + arg.value.endsWith(customModule) + ) + ) { + importedCustomModuleNode = callExpression; + } + + if ( + !importedCustomModuleNode && + args.some( + (arg) => + isLiteral(arg) && + typeof arg.value === 'string' && + arg.value === USER_EVENT_PACKAGE + ) + ) { + importedUserEventLibraryNode = callExpression; + } + }, + }; + + // update given rule to inject Testing Library detection + const ruleInstructions = ruleCreate(context, optionsWithDefault, helpers); + const enhancedRuleInstructions: TSESLint.RuleListener = {}; + + const allKeys = new Set( + Object.keys(detectionInstructions).concat(Object.keys(ruleInstructions)) + ); + + // Iterate over ALL instructions keys so we can override original rule instructions + // to prevent their execution if conditions to report errors are not met. + allKeys.forEach((instruction) => { + enhancedRuleInstructions[instruction] = (node) => { + if (instruction in detectionInstructions) { + detectionInstructions[instruction]?.(node); + } + + if (canReportErrors() && ruleInstructions[instruction]) { + return ruleInstructions[instruction]?.(node); + } + }; + }); + + return enhancedRuleInstructions; + }; +} diff --git a/lib/index.ts b/lib/index.ts index cb35f058..4e874216 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -4,17 +4,24 @@ import awaitFireEvent from './rules/await-fire-event'; import consistentDataTestid from './rules/consistent-data-testid'; import noAwaitSyncEvents from './rules/no-await-sync-events'; import noAwaitSyncQuery from './rules/no-await-sync-query'; +import noContainer from './rules/no-container'; import noDebug from './rules/no-debug'; import noDomImport from './rules/no-dom-import'; import noManualCleanup from './rules/no-manual-cleanup'; +import noNodeAccess from './rules/no-node-access'; +import noPromiseInFireEvent from './rules/no-promise-in-fire-event'; import noRenderInSetup from './rules/no-render-in-setup'; import noWaitForEmptyCallback from './rules/no-wait-for-empty-callback'; import noWaitForSnapshot from './rules/no-wait-for-snapshot'; import preferExplicitAssert from './rules/prefer-explicit-assert'; import preferPresenceQueries from './rules/prefer-presence-queries'; import preferScreenQueries from './rules/prefer-screen-queries'; +import preferUserEvent from './rules/prefer-user-event'; import preferWaitFor from './rules/prefer-wait-for'; +import noWaitForMultipleAssertions from './rules/no-wait-for-multiple-assertions'; import preferFindBy from './rules/prefer-find-by'; +import noWaitForSideEffects from './rules/no-wait-for-side-effects'; +import renderResultNamingConvention from './rules/render-result-naming-convention'; const rules = { 'await-async-query': awaitAsyncQuery, @@ -23,57 +30,82 @@ const rules = { 'consistent-data-testid': consistentDataTestid, 'no-await-sync-events': noAwaitSyncEvents, 'no-await-sync-query': noAwaitSyncQuery, + 'no-container': noContainer, 'no-debug': noDebug, 'no-dom-import': noDomImport, 'no-manual-cleanup': noManualCleanup, + 'no-node-access': noNodeAccess, + 'no-promise-in-fire-event': noPromiseInFireEvent, 'no-render-in-setup': noRenderInSetup, 'no-wait-for-empty-callback': noWaitForEmptyCallback, + 'no-wait-for-multiple-assertions': noWaitForMultipleAssertions, + 'no-wait-for-side-effects': noWaitForSideEffects, 'no-wait-for-snapshot': noWaitForSnapshot, 'prefer-explicit-assert': preferExplicitAssert, 'prefer-find-by': preferFindBy, 'prefer-presence-queries': preferPresenceQueries, 'prefer-screen-queries': preferScreenQueries, + 'prefer-user-event': preferUserEvent, 'prefer-wait-for': preferWaitFor, + 'render-result-naming-convention': renderResultNamingConvention, }; -const recommendedRules = { +const domRules = { 'testing-library/await-async-query': 'error', 'testing-library/await-async-utils': 'error', 'testing-library/no-await-sync-query': 'error', + 'testing-library/no-promise-in-fire-event': 'error', + 'testing-library/no-wait-for-empty-callback': 'error', 'testing-library/prefer-find-by': 'error', + 'testing-library/prefer-screen-queries': 'error', +}; + +const angularRules = { + ...domRules, + 'testing-library/no-container': 'error', + 'testing-library/no-debug': 'error', + 'testing-library/no-dom-import': ['error', 'angular'], + 'testing-library/no-node-access': 'error', + 'testing-library/render-result-naming-convention': 'error', +}; + +const reactRules = { + ...domRules, + 'testing-library/no-container': 'error', + 'testing-library/no-debug': 'error', + 'testing-library/no-dom-import': ['error', 'react'], + 'testing-library/no-node-access': 'error', + 'testing-library/render-result-naming-convention': 'error', +}; + +const vueRules = { + ...domRules, + 'testing-library/await-fire-event': 'error', + 'testing-library/no-container': 'error', + 'testing-library/no-debug': 'error', + 'testing-library/no-dom-import': ['error', 'vue'], + 'testing-library/no-node-access': 'error', + 'testing-library/render-result-naming-convention': 'error', }; export = { rules, configs: { - recommended: { + dom: { plugins: ['testing-library'], - rules: recommendedRules, + rules: domRules, }, angular: { plugins: ['testing-library'], - rules: { - ...recommendedRules, - 'testing-library/no-debug': 'warn', - 'testing-library/no-dom-import': ['error', 'angular'], - }, + rules: angularRules, }, react: { plugins: ['testing-library'], - rules: { - ...recommendedRules, - 'testing-library/no-debug': 'warn', - 'testing-library/no-dom-import': ['error', 'react'], - }, + rules: reactRules, }, vue: { plugins: ['testing-library'], - rules: { - ...recommendedRules, - 'testing-library/await-fire-event': 'error', - 'testing-library/no-debug': 'warn', - 'testing-library/no-dom-import': ['error', 'vue'], - }, + rules: vueRules, }, }, }; diff --git a/lib/node-utils.ts b/lib/node-utils.ts index 86063c71..453f7c98 100644 --- a/lib/node-utils.ts +++ b/lib/node-utils.ts @@ -1,114 +1,150 @@ import { AST_NODE_TYPES, + ASTUtils, + TSESLint, + TSESLintScope, TSESTree, } from '@typescript-eslint/experimental-utils'; import { RuleContext } from '@typescript-eslint/experimental-utils/dist/ts-eslint'; +const ValidLeftHandSideExpressions = [ + AST_NODE_TYPES.CallExpression, + AST_NODE_TYPES.ClassExpression, + AST_NODE_TYPES.ClassDeclaration, + AST_NODE_TYPES.FunctionExpression, + AST_NODE_TYPES.Literal, + AST_NODE_TYPES.TemplateLiteral, + AST_NODE_TYPES.MemberExpression, + AST_NODE_TYPES.ArrayExpression, + AST_NODE_TYPES.ArrayPattern, + AST_NODE_TYPES.ClassExpression, + AST_NODE_TYPES.FunctionExpression, + AST_NODE_TYPES.Identifier, + AST_NODE_TYPES.JSXElement, + AST_NODE_TYPES.JSXFragment, + AST_NODE_TYPES.JSXOpeningElement, + AST_NODE_TYPES.MetaProperty, + AST_NODE_TYPES.ObjectExpression, + AST_NODE_TYPES.ObjectPattern, + AST_NODE_TYPES.Super, + AST_NODE_TYPES.ThisExpression, + AST_NODE_TYPES.TSNullKeyword, + AST_NODE_TYPES.TaggedTemplateExpression, + AST_NODE_TYPES.TSNonNullExpression, + AST_NODE_TYPES.TSAsExpression, + AST_NODE_TYPES.ArrowFunctionExpression, +]; + export function isCallExpression( - node: TSESTree.Node + node: TSESTree.Node | null | undefined ): node is TSESTree.CallExpression { - return node && node.type === AST_NODE_TYPES.CallExpression; + return node?.type === AST_NODE_TYPES.CallExpression; } -export function isIdentifier(node: TSESTree.Node): node is TSESTree.Identifier { - return node && node.type === AST_NODE_TYPES.Identifier; +export function isNewExpression( + node: TSESTree.Node | null | undefined +): node is TSESTree.NewExpression { + return node?.type === 'NewExpression'; } export function isMemberExpression( - node: TSESTree.Node + node: TSESTree.Node | null | undefined ): node is TSESTree.MemberExpression { - return node && node.type === AST_NODE_TYPES.MemberExpression; + return node?.type === AST_NODE_TYPES.MemberExpression; } -export function isLiteral(node: TSESTree.Node): node is TSESTree.Literal { - return node && node.type === AST_NODE_TYPES.Literal; +export function isLiteral( + node: TSESTree.Node | null | undefined +): node is TSESTree.Literal { + return node?.type === AST_NODE_TYPES.Literal; } export function isImportSpecifier( - node: TSESTree.Node + node: TSESTree.Node | null | undefined ): node is TSESTree.ImportSpecifier { - return node && node.type === AST_NODE_TYPES.ImportSpecifier; + return node?.type === AST_NODE_TYPES.ImportSpecifier; } export function isImportNamespaceSpecifier( - node: TSESTree.Node + node: TSESTree.Node | null | undefined ): node is TSESTree.ImportNamespaceSpecifier { - return node?.type === AST_NODE_TYPES.ImportNamespaceSpecifier + return node?.type === AST_NODE_TYPES.ImportNamespaceSpecifier; } export function isImportDefaultSpecifier( - node: TSESTree.Node + node: TSESTree.Node | null | undefined ): node is TSESTree.ImportDefaultSpecifier { - return node && node.type === AST_NODE_TYPES.ImportDefaultSpecifier; + return node?.type === AST_NODE_TYPES.ImportDefaultSpecifier; } export function isBlockStatement( - node: TSESTree.Node + node: TSESTree.Node | null | undefined ): node is TSESTree.BlockStatement { - return node && node.type === AST_NODE_TYPES.BlockStatement; -} - -export function isVariableDeclarator( - node: TSESTree.Node -): node is TSESTree.VariableDeclarator { - return node && node.type === AST_NODE_TYPES.VariableDeclarator; -} - -export function isRenderFunction( - callNode: TSESTree.CallExpression, - renderFunctions: string[] -) { - // returns true for `render` and e.g. `customRenderFn` - // as well as `someLib.render` and `someUtils.customRenderFn` - return renderFunctions.some(name => { - return ( - (isIdentifier(callNode.callee) && name === callNode.callee.name) || - (isMemberExpression(callNode.callee) && - isIdentifier(callNode.callee.property) && - name === callNode.callee.property.name) - ); - }); + return node?.type === AST_NODE_TYPES.BlockStatement; } export function isObjectPattern( - node: TSESTree.Node + node: TSESTree.Node | null | undefined ): node is TSESTree.ObjectPattern { - return node && node.type === AST_NODE_TYPES.ObjectPattern; + return node?.type === AST_NODE_TYPES.ObjectPattern; } -export function isProperty(node: TSESTree.Node): node is TSESTree.Property { - return node && node.type === AST_NODE_TYPES.Property; +export function isProperty( + node: TSESTree.Node | null | undefined +): node is TSESTree.Property { + return node?.type === AST_NODE_TYPES.Property; } export function isJSXAttribute( - node: TSESTree.Node + node: TSESTree.Node | null | undefined ): node is TSESTree.JSXAttribute { - return node && node.type === AST_NODE_TYPES.JSXAttribute; + return node?.type === AST_NODE_TYPES.JSXAttribute; } +export function isExpressionStatement( + node: TSESTree.Node | null | undefined +): node is TSESTree.ExpressionStatement { + return node?.type === AST_NODE_TYPES.ExpressionStatement; +} + +/** + * Finds the closest CallExpression node for a given node. + * @param node + * @param shouldRestrictInnerScope - If true, CallExpression must belong to innermost scope of given node + */ export function findClosestCallExpressionNode( - node: TSESTree.Node -): TSESTree.CallExpression { + node: TSESTree.Node, + shouldRestrictInnerScope = false +): TSESTree.CallExpression | null { if (isCallExpression(node)) { return node; } - if (!node.parent) return null; + if (!node || !node.parent) { + return null; + } + + if ( + shouldRestrictInnerScope && + !ValidLeftHandSideExpressions.includes(node.parent.type) + ) { + return null; + } - return findClosestCallExpressionNode(node.parent); + return findClosestCallExpressionNode(node.parent, shouldRestrictInnerScope); } export function findClosestCallNode( node: TSESTree.Node, name: string -): TSESTree.CallExpression { +): TSESTree.CallExpression | null { if (!node.parent) { return null; } if ( isCallExpression(node) && - isIdentifier(node.callee) && + ASTUtils.isIdentifier(node.callee) && node.callee.name === name ) { return node; @@ -123,65 +159,442 @@ export function isObjectExpression( return node?.type === AST_NODE_TYPES.ObjectExpression; } -export function hasThenProperty(node: TSESTree.Node) { +export function hasThenProperty(node: TSESTree.Node): boolean { return ( isMemberExpression(node) && - isIdentifier(node.property) && + ASTUtils.isIdentifier(node.property) && node.property.name === 'then' ); } -export function isAwaitExpression( - node: TSESTree.Node -): node is TSESTree.AwaitExpression { - return node && node.type === AST_NODE_TYPES.AwaitExpression; -} - export function isArrowFunctionExpression( node: TSESTree.Node ): node is TSESTree.ArrowFunctionExpression { - return node && node.type === AST_NODE_TYPES.ArrowFunctionExpression; + return node?.type === AST_NODE_TYPES.ArrowFunctionExpression; } export function isReturnStatement( node: TSESTree.Node ): node is TSESTree.ReturnStatement { - return node && node.type === AST_NODE_TYPES.ReturnStatement; + return node?.type === AST_NODE_TYPES.ReturnStatement; } export function isArrayExpression( node: TSESTree.Node ): node is TSESTree.ArrayExpression { - return node?.type === AST_NODE_TYPES.ArrayExpression + return node?.type === AST_NODE_TYPES.ArrayExpression; } -export function isAwaited(node: TSESTree.Node) { - return ( - isAwaitExpression(node) || - isArrowFunctionExpression(node) || - isReturnStatement(node) - ); +export function isImportDeclaration( + node: TSESTree.Node | null | undefined +): node is TSESTree.ImportDeclaration { + return node?.type === AST_NODE_TYPES.ImportDeclaration; } -export function isPromiseResolved(node: TSESTree.Node) { +export function hasChainedThen(node: TSESTree.Node): boolean { const parent = node.parent; // wait(...).then(...) - if (isCallExpression(parent)) { + if (isCallExpression(parent) && parent.parent) { return hasThenProperty(parent.parent); } // promise.then(...) - return hasThenProperty(parent); + return !!parent && hasThenProperty(parent); +} + +export function isPromiseIdentifier( + node: TSESTree.Node +): node is TSESTree.Identifier & { name: 'Promise' } { + return ASTUtils.isIdentifier(node) && node.name === 'Promise'; +} + +export function isPromiseAll(node: TSESTree.CallExpression): boolean { + return ( + isMemberExpression(node.callee) && + isPromiseIdentifier(node.callee.object) && + ASTUtils.isIdentifier(node.callee.property) && + node.callee.property.name === 'all' + ); +} + +export function isPromiseAllSettled(node: TSESTree.CallExpression): boolean { + return ( + isMemberExpression(node.callee) && + isPromiseIdentifier(node.callee.object) && + ASTUtils.isIdentifier(node.callee.property) && + node.callee.property.name === 'allSettled' + ); +} + +/** + * Determines whether a given node belongs to handled Promise.all or Promise.allSettled + * array expression. + */ +export function isPromisesArrayResolved(node: TSESTree.Node): boolean { + const closestCallExpression = findClosestCallExpressionNode(node, true); + + if (!closestCallExpression) { + return false; + } + + return ( + !!closestCallExpression.parent && + isArrayExpression(closestCallExpression.parent) && + isCallExpression(closestCallExpression.parent.parent) && + (isPromiseAll(closestCallExpression.parent.parent) || + isPromiseAllSettled(closestCallExpression.parent.parent)) + ); +} + +/** + * Determines whether an Identifier related to a promise is considered as handled. + * + * It will be considered as handled if: + * - it belongs to the `await` expression + * - it belongs to the `Promise.all` method + * - it belongs to the `Promise.allSettled` method + * - it's chained with the `then` method + * - it's returned from a function + * - has `resolves` or `rejects` jest methods + */ +export function isPromiseHandled(nodeIdentifier: TSESTree.Identifier): boolean { + const closestCallExpressionNode = findClosestCallExpressionNode( + nodeIdentifier, + true + ); + + const suspiciousNodes = [nodeIdentifier, closestCallExpressionNode].filter( + Boolean + ); + + for (const node of suspiciousNodes) { + if (!node || !node.parent) { + continue; + } + if (ASTUtils.isAwaitExpression(node.parent)) { + return true; + } + + if ( + isArrowFunctionExpression(node.parent) || + isReturnStatement(node.parent) + ) { + return true; + } + + if (hasClosestExpectResolvesRejects(node.parent)) { + return true; + } + + if (hasChainedThen(node)) { + return true; + } + + if (isPromisesArrayResolved(node)) { + return true; + } + } + + return false; } export function getVariableReferences( context: RuleContext, node: TSESTree.Node -) { +): TSESLint.Scope.Reference[] { return ( - (isVariableDeclarator(node) && - context.getDeclaredVariables(node)[0].references.slice(1)) || + (ASTUtils.isVariableDeclarator(node) && + context.getDeclaredVariables(node)[0]?.references?.slice(1)) || [] ); } + +interface InnermostFunctionScope extends TSESLintScope.FunctionScope { + block: + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression; +} + +export function getInnermostFunctionScope( + context: RuleContext, + asyncQueryNode: TSESTree.Identifier +): InnermostFunctionScope | null { + const innermostScope = ASTUtils.getInnermostScope( + context.getScope(), + asyncQueryNode + ); + + if ( + innermostScope?.type === 'function' && + ASTUtils.isFunction(innermostScope.block) + ) { + return (innermostScope as unknown) as InnermostFunctionScope; + } + + return null; +} + +export function getFunctionReturnStatementNode( + functionNode: + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression +): TSESTree.Node | null { + if (isBlockStatement(functionNode.body)) { + // regular function or arrow function with block + const returnStatementNode = functionNode.body.body.find((statement) => + isReturnStatement(statement) + ) as TSESTree.ReturnStatement | undefined; + + if (!returnStatementNode) { + return null; + } + return returnStatementNode.argument; + } else if (functionNode.expression) { + // arrow function with implicit return + return functionNode.body; + } + + return null; +} + +/** + * Gets the property identifier node of a given property node. + * + * Not to be confused with {@link getDeepestIdentifierNode} + * + * An example: + * Having `const a = rtl.within('foo').getByRole('button')`: + * if we call `getPropertyIdentifierNode` with `rtl` property node, + * it will return `rtl` identifier node + */ +export function getPropertyIdentifierNode( + node: TSESTree.Node +): TSESTree.Identifier | null { + if (ASTUtils.isIdentifier(node)) { + return node; + } + + if (isMemberExpression(node)) { + return getPropertyIdentifierNode(node.object); + } + + if (isCallExpression(node)) { + return getPropertyIdentifierNode(node.callee); + } + + if (isExpressionStatement(node)) { + return getPropertyIdentifierNode(node.expression); + } + + return null; +} + +/** + * Gets the deepest identifier node in the expression from a given node. + * + * Opposite of {@link getReferenceNode} + * + * An example: + * Having `const a = rtl.within('foo').getByRole('button')`: + * if we call `getDeepestIdentifierNode` with `rtl` node, + * it will return `getByRole` identifier + */ +export function getDeepestIdentifierNode( + node: TSESTree.Node +): TSESTree.Identifier | null { + if (ASTUtils.isIdentifier(node)) { + return node; + } + + if (isMemberExpression(node) && ASTUtils.isIdentifier(node.property)) { + return node.property; + } + + if (isCallExpression(node)) { + return getDeepestIdentifierNode(node.callee); + } + + if (ASTUtils.isAwaitExpression(node)) { + return getDeepestIdentifierNode(node.argument); + } + + return null; +} + +/** + * Gets the farthest node in the expression from a given node. + * + * Opposite of {@link getDeepestIdentifierNode} + + * An example: + * Having `const a = rtl.within('foo').getByRole('button')`: + * if we call `getReferenceNode` with `getByRole` identifier, + * it will return `rtl` node + */ +export function getReferenceNode( + node: + | TSESTree.CallExpression + | TSESTree.MemberExpression + | TSESTree.Identifier +): TSESTree.CallExpression | TSESTree.MemberExpression | TSESTree.Identifier { + if ( + node.parent && + (isMemberExpression(node.parent) || isCallExpression(node.parent)) + ) { + return getReferenceNode(node.parent); + } + + return node; +} + +export function getFunctionName( + node: + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression +): string { + return ( + ASTUtils.getFunctionNameWithKind(node) + .match(/('\w+')/g)?.[0] + .replace(/'/g, '') ?? '' + ); +} + +// TODO: extract into types file? +export type ImportModuleNode = + | TSESTree.ImportDeclaration + | TSESTree.CallExpression; + +export function getImportModuleName( + node: ImportModuleNode | undefined | null +): string | undefined { + // import node of shape: import { foo } from 'bar' + if (isImportDeclaration(node) && typeof node.source.value === 'string') { + return node.source.value; + } + + // import node of shape: const { foo } = require('bar') + if ( + isCallExpression(node) && + isLiteral(node.arguments[0]) && + typeof node.arguments[0].value === 'string' + ) { + return node.arguments[0].value; + } +} + +type AssertNodeInfo = { + matcher: string | null; + isNegated: boolean; +}; +/** + * Extracts matcher info from MemberExpression node representing an assert. + */ +export function getAssertNodeInfo( + node: TSESTree.MemberExpression +): AssertNodeInfo { + const emptyInfo = { matcher: null, isNegated: false } as AssertNodeInfo; + + if ( + !isCallExpression(node.object) || + !ASTUtils.isIdentifier(node.object.callee) + ) { + return emptyInfo; + } + + if (node.object.callee.name !== 'expect') { + return emptyInfo; + } + + let matcher = ASTUtils.getPropertyName(node); + const isNegated = matcher === 'not'; + if (isNegated) { + matcher = + node.parent && isMemberExpression(node.parent) + ? ASTUtils.getPropertyName(node.parent) + : null; + } + + if (!matcher) { + return emptyInfo; + } + + return { matcher, isNegated }; +} + +/** + * Determines whether a node belongs to an async assertion + * fulfilled by `resolves` or `rejects` properties. + * + */ +export function hasClosestExpectResolvesRejects(node: TSESTree.Node): boolean { + if ( + isCallExpression(node) && + ASTUtils.isIdentifier(node.callee) && + node.parent && + isMemberExpression(node.parent) && + node.callee.name === 'expect' + ) { + const expectMatcher = node.parent.property; + return ( + ASTUtils.isIdentifier(expectMatcher) && + (expectMatcher.name === 'resolves' || expectMatcher.name === 'rejects') + ); + } + + if (!node.parent) { + return false; + } + + return hasClosestExpectResolvesRejects(node.parent); +} + +/** + * Gets the Function node which returns the given Identifier. + */ +export function getInnermostReturningFunction( + context: RuleContext, + node: TSESTree.Identifier +): + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression + | undefined { + const functionScope = getInnermostFunctionScope(context, node); + + if (!functionScope) { + return; + } + + const returnStatementNode = getFunctionReturnStatementNode( + functionScope.block + ); + + if (!returnStatementNode) { + return; + } + + const returnStatementIdentifier = getDeepestIdentifierNode( + returnStatementNode + ); + + if (returnStatementIdentifier?.name !== node.name) { + return; + } + + return functionScope.block; +} + +export function hasImportMatch( + importNode: TSESTree.ImportClause | TSESTree.Identifier, + identifierName: string +): boolean { + if (ASTUtils.isIdentifier(importNode)) { + return importNode.name === identifierName; + } + + return importNode.local.name === identifierName; +} diff --git a/lib/rules/await-async-query.ts b/lib/rules/await-async-query.ts index 10d8d658..6acf99ca 100644 --- a/lib/rules/await-async-query.ts +++ b/lib/rules/await-async-query.ts @@ -1,139 +1,102 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl, LIBRARY_MODULES } from '../utils'; +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; import { - isCallExpression, - isIdentifier, - isMemberExpression, - isAwaited, - isPromiseResolved, + findClosestCallExpressionNode, + getFunctionName, + getInnermostReturningFunction, getVariableReferences, + isPromiseHandled, } from '../node-utils'; -import { ReportDescriptor } from '@typescript-eslint/experimental-utils/dist/ts-eslint'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'await-async-query'; -export type MessageIds = 'awaitAsyncQuery'; +export type MessageIds = 'awaitAsyncQuery' | 'asyncQueryWrapper'; type Options = []; -const ASYNC_QUERIES_REGEXP = /^find(All)?By(LabelText|PlaceholderText|Text|AltText|Title|DisplayValue|Role|TestId)$/; - -function hasClosestExpectResolvesRejects(node: TSESTree.Node): boolean { - if (!node.parent) { - return false; - } - - if ( - isCallExpression(node) && - isIdentifier(node.callee) && - isMemberExpression(node.parent) && - node.callee.name === 'expect' - ) { - const expectMatcher = node.parent.property; - return ( - isIdentifier(expectMatcher) && - (expectMatcher.name === 'resolves' || expectMatcher.name === 'rejects') - ); - } else { - return hasClosestExpectResolvesRejects(node.parent); - } -} - -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', docs: { - description: 'Enforce async queries to have proper `await`', + description: 'Enforce promises from async queries to be handled', category: 'Best Practices', recommended: 'warn', }, messages: { - awaitAsyncQuery: '`{{ name }}` must have `await` operator', + awaitAsyncQuery: 'promise returned from {{ name }} query must be handled', + asyncQueryWrapper: + 'promise returned from {{ name }} wrapper over async query must be handled', }, - fixable: null, schema: [], }, defaultOptions: [], - create(context) { - const testingLibraryQueryUsage: { - node: TSESTree.Identifier | TSESTree.MemberExpression; - queryName: string; - }[] = []; + create(context, _, helpers) { + const functionWrappersNames: string[] = []; - const isQueryUsage = ( - node: TSESTree.Identifier | TSESTree.MemberExpression - ) => - !isAwaited(node.parent.parent) && - !isPromiseResolved(node) && - !hasClosestExpectResolvesRejects(node); - - let hasImportedFromTestingLibraryModule = false; - - function report(params: ReportDescriptor<'awaitAsyncQuery'>) { - if (hasImportedFromTestingLibraryModule) { - context.report(params); + function detectAsyncQueryWrapper(node: TSESTree.Identifier) { + const innerFunction = getInnermostReturningFunction(context, node); + if (innerFunction) { + functionWrappersNames.push(getFunctionName(innerFunction)); } } return { - 'ImportDeclaration > ImportSpecifier,ImportNamespaceSpecifier'( - node: TSESTree.Node - ) { - const importDeclaration = node.parent as TSESTree.ImportDeclaration; - const module = importDeclaration.source.value.toString(); + 'CallExpression Identifier'(node: TSESTree.Identifier) { + if (helpers.isAsyncQuery(node)) { + // detect async query used within wrapper function for later analysis + detectAsyncQueryWrapper(node); - if (LIBRARY_MODULES.includes(module)) { - hasImportedFromTestingLibraryModule = true; - } - }, - [`CallExpression > Identifier[name=${ASYNC_QUERIES_REGEXP}]`]( - node: TSESTree.Identifier - ) { - if (isQueryUsage(node)) { - testingLibraryQueryUsage.push({ node, queryName: node.name }); - } - }, - [`MemberExpression > Identifier[name=${ASYNC_QUERIES_REGEXP}]`]( - node: TSESTree.Identifier - ) { - // Perform checks in parent MemberExpression instead of current identifier - const parent = node.parent as TSESTree.MemberExpression; - if (isQueryUsage(parent)) { - testingLibraryQueryUsage.push({ node: parent, queryName: node.name }); - } - }, - 'Program:exit'() { - testingLibraryQueryUsage.forEach(({ node, queryName }) => { - const references = getVariableReferences(context, node.parent.parent); + const closestCallExpressionNode = findClosestCallExpressionNode( + node, + true + ); + if (!closestCallExpressionNode || !closestCallExpressionNode.parent) { + return; + } + + const references = getVariableReferences( + context, + closestCallExpressionNode.parent + ); + + // check direct usage of async query: + // const element = await findByRole('button') if (references && references.length === 0) { - report({ - node, - messageId: 'awaitAsyncQuery', - data: { - name: queryName, - }, - }); - } else { - for (const reference of references) { - const referenceNode = reference.identifier; - if ( - !isAwaited(referenceNode.parent) && - !isPromiseResolved(referenceNode) - ) { - report({ - node, - messageId: 'awaitAsyncQuery', - data: { - name: queryName, - }, - }); + if (!isPromiseHandled(node)) { + return context.report({ + node, + messageId: 'awaitAsyncQuery', + data: { name: node.name }, + }); + } + } - break; - } + // check references usages of async query: + // const promise = findByRole('button') + // const element = await promise + for (const reference of references) { + if ( + ASTUtils.isIdentifier(reference.identifier) && + !isPromiseHandled(reference.identifier) + ) { + return context.report({ + node, + messageId: 'awaitAsyncQuery', + data: { name: node.name }, + }); } } - }); + } else if (functionWrappersNames.includes(node.name)) { + // check async queries used within a wrapper previously detected + if (!isPromiseHandled(node)) { + return context.report({ + node, + messageId: 'asyncQueryWrapper', + data: { name: node.name }, + }); + } + } }, }; }, diff --git a/lib/rules/await-async-utils.ts b/lib/rules/await-async-utils.ts index 96e81933..c4e40373 100644 --- a/lib/rules/await-async-utils.ts +++ b/lib/rules/await-async-utils.ts @@ -1,140 +1,100 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; - -import { getDocsUrl, ASYNC_UTILS, LIBRARY_MODULES } from '../utils'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; import { - isAwaited, - isPromiseResolved, + findClosestCallExpressionNode, + getFunctionName, + getInnermostReturningFunction, getVariableReferences, - isMemberExpression, - isImportSpecifier, - isImportNamespaceSpecifier, - isCallExpression, - isArrayExpression, - isIdentifier + isPromiseHandled, } from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'await-async-utils'; -export type MessageIds = 'awaitAsyncUtil'; +export type MessageIds = 'awaitAsyncUtil' | 'asyncUtilWrapper'; type Options = []; -const ASYNC_UTILS_REGEXP = new RegExp(`^(${ASYNC_UTILS.join('|')})$`); - -// verifies the CallExpression is Promise.all() -function isPromiseAll(node: TSESTree.CallExpression) { - return isMemberExpression(node.callee) && isIdentifier(node.callee.object) && node.callee.object.name === 'Promise' && isIdentifier(node.callee.property) && node.callee.property.name === 'all' -} - -// verifies the node is part of an array used in a CallExpression -function isInPromiseAll(node: TSESTree.Node) { - const parent = node.parent - return isCallExpression(parent) && isArrayExpression(parent.parent) && isCallExpression(parent.parent.parent) && isPromiseAll(parent.parent.parent) -} - -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', docs: { - description: 'Enforce async utils to be awaited properly', + description: 'Enforce promises from async utils to be handled', category: 'Best Practices', recommended: 'warn', }, messages: { awaitAsyncUtil: 'Promise returned from `{{ name }}` must be handled', + asyncUtilWrapper: + 'Promise returned from {{ name }} wrapper over async util must be handled', }, - fixable: null, schema: [], }, defaultOptions: [], - create(context) { - const asyncUtilsUsage: Array<{ - node: TSESTree.Identifier | TSESTree.MemberExpression; - name: string; - }> = []; - const importedAsyncUtils: string[] = []; - - return { - 'ImportDeclaration > ImportSpecifier,ImportNamespaceSpecifier'( - node: TSESTree.Node - ) { - const parent = node.parent as TSESTree.ImportDeclaration; - - if (!LIBRARY_MODULES.includes(parent.source.value.toString())) return; - - if (isImportSpecifier(node)) { - importedAsyncUtils.push(node.imported.name); - } + create(context, _, helpers) { + const functionWrappersNames: string[] = []; - if (isImportNamespaceSpecifier(node)) { - importedAsyncUtils.push(node.local.name); - } - }, - [`CallExpression > Identifier[name=${ASYNC_UTILS_REGEXP}]`]( - node: TSESTree.Identifier - ) { - asyncUtilsUsage.push({ node, name: node.name }); - }, - [`CallExpression > MemberExpression > Identifier[name=${ASYNC_UTILS_REGEXP}]`]( - node: TSESTree.Identifier - ) { - const memberExpression = node.parent as TSESTree.MemberExpression; - const identifier = memberExpression.object as TSESTree.Identifier; - const memberExpressionName = identifier.name; + function detectAsyncUtilWrapper(node: TSESTree.Identifier) { + const innerFunction = getInnermostReturningFunction(context, node); - asyncUtilsUsage.push({ - node: memberExpression, - name: memberExpressionName, - }); - }, - 'Program:exit'() { - const testingLibraryUtilUsage = asyncUtilsUsage.filter(usage => { - if (isMemberExpression(usage.node)) { - const object = usage.node.object as TSESTree.Identifier; + if (innerFunction) { + functionWrappersNames.push(getFunctionName(innerFunction)); + } + } - return importedAsyncUtils.includes(object.name); + return { + 'CallExpression Identifier'(node: TSESTree.Identifier) { + if (helpers.isAsyncUtil(node)) { + // detect async query used within wrapper function for later analysis + detectAsyncUtilWrapper(node); + + const closestCallExpression = findClosestCallExpressionNode( + node, + true + ); + + if (!closestCallExpression || !closestCallExpression.parent) { + return; } - return importedAsyncUtils.includes(usage.name); - }); - - testingLibraryUtilUsage.forEach(({ node, name }) => { - const references = getVariableReferences(context, node.parent.parent); - - if ( - references && - references.length === 0 && - !isAwaited(node.parent.parent) && - !isPromiseResolved(node) && - !isInPromiseAll(node) - ) { - context.report({ - node, - messageId: 'awaitAsyncUtil', - data: { - name, - }, - }); + const references = getVariableReferences( + context, + closestCallExpression.parent + ); + + if (references && references.length === 0) { + if (!isPromiseHandled(node)) { + return context.report({ + node, + messageId: 'awaitAsyncUtil', + data: { + name: node.name, + }, + }); + } } else { for (const reference of references) { - const referenceNode = reference.identifier; - if ( - !isAwaited(referenceNode.parent) && - !isPromiseResolved(referenceNode) - ) { - context.report({ + const referenceNode = reference.identifier as TSESTree.Identifier; + if (!isPromiseHandled(referenceNode)) { + return context.report({ node, messageId: 'awaitAsyncUtil', data: { - name, + name: node.name, }, }); - - break; } } } - }); + } else if (functionWrappersNames.includes(node.name)) { + // check async queries used within a wrapper previously detected + if (!isPromiseHandled(node)) { + return context.report({ + node, + messageId: 'asyncUtilWrapper', + data: { name: node.name }, + }); + } + } }, }; }, diff --git a/lib/rules/await-fire-event.ts b/lib/rules/await-fire-event.ts index 71ea463e..7f635bc8 100644 --- a/lib/rules/await-fire-event.ts +++ b/lib/rules/await-fire-event.ts @@ -1,47 +1,105 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl } from '../utils'; -import { isIdentifier, isAwaited, isPromiseResolved } from '../node-utils'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { + findClosestCallExpressionNode, + getFunctionName, + getInnermostReturningFunction, + getVariableReferences, + isPromiseHandled, +} from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'await-fire-event'; -export type MessageIds = 'awaitFireEvent'; +export type MessageIds = 'awaitFireEvent' | 'fireEventWrapper'; type Options = []; -export default ESLintUtils.RuleCreator(getDocsUrl)({ + +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', docs: { - description: 'Enforce async fire event methods to be awaited', + description: 'Enforce promises from fire event methods to be handled', category: 'Best Practices', recommended: false, }, messages: { - awaitFireEvent: 'async `fireEvent.{{ methodName }}` must be awaited', + awaitFireEvent: + 'Promise returned from `fireEvent.{{ methodName }}` must be handled', + fireEventWrapper: + 'Promise returned from `fireEvent.{{ wrapperName }}` wrapper over fire event method must be handled', }, - fixable: null, schema: [], }, defaultOptions: [], - create: function(context) { + create: function (context, _, helpers) { + const functionWrappersNames: string[] = []; + + function reportUnhandledNode( + node: TSESTree.Identifier, + closestCallExpressionNode: TSESTree.CallExpression, + messageId: MessageIds = 'awaitFireEvent' + ): void { + if (!isPromiseHandled(node)) { + context.report({ + node: closestCallExpressionNode.callee, + messageId, + data: { name: node.name }, + }); + } + } + + function detectFireEventMethodWrapper(node: TSESTree.Identifier): void { + const innerFunction = getInnermostReturningFunction(context, node); + + if (innerFunction) { + functionWrappersNames.push(getFunctionName(innerFunction)); + } + } + return { - 'CallExpression > MemberExpression > Identifier[name=fireEvent]'( - node: TSESTree.Identifier - ) { - const memberExpression = node.parent as TSESTree.MemberExpression; - const fireEventMethodNode = memberExpression.property; - - if ( - isIdentifier(fireEventMethodNode) && - !isAwaited(node.parent.parent.parent) && - !isPromiseResolved(fireEventMethodNode.parent) - ) { - context.report({ - node: fireEventMethodNode, - messageId: 'awaitFireEvent', - data: { - methodName: fireEventMethodNode.name, - }, - }); + 'CallExpression Identifier'(node: TSESTree.Identifier) { + if (helpers.isFireEventMethod(node)) { + detectFireEventMethodWrapper(node); + + const closestCallExpression = findClosestCallExpressionNode( + node, + true + ); + + if (!closestCallExpression || !closestCallExpression.parent) { + return; + } + + const references = getVariableReferences( + context, + closestCallExpression.parent + ); + + if (references.length === 0) { + return reportUnhandledNode(node, closestCallExpression); + } else { + for (const reference of references) { + const referenceNode = reference.identifier as TSESTree.Identifier; + return reportUnhandledNode(referenceNode, closestCallExpression); + } + } + } else if (functionWrappersNames.includes(node.name)) { + // report promise returned from function wrapping fire event method + // previously detected + const closestCallExpression = findClosestCallExpressionNode( + node, + true + ); + + if (!closestCallExpression) { + return; + } + + return reportUnhandledNode( + node, + closestCallExpression, + 'fireEventWrapper' + ); } }, }; diff --git a/lib/rules/consistent-data-testid.ts b/lib/rules/consistent-data-testid.ts index a66da565..f31a7b53 100644 --- a/lib/rules/consistent-data-testid.ts +++ b/lib/rules/consistent-data-testid.ts @@ -1,9 +1,9 @@ import { getDocsUrl } from '../utils'; -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; import { isJSXAttribute, isLiteral } from '../node-utils'; export const RULE_NAME = 'consistent-data-testid'; -export type MessageIds = 'invalidTestId'; +export type MessageIds = 'consistentDataTestId'; type Options = [ { testIdAttribute?: string | string[]; @@ -13,6 +13,11 @@ type Options = [ const FILENAME_PLACEHOLDER = '{fileName}'; +/** + * This rule is not created with `createTestingLibraryRule` since: + * - it doesn't need any detection helper + * - it doesn't apply to testing files but component files + */ export default ESLintUtils.RuleCreator(getDocsUrl)({ name: RULE_NAME, meta: { @@ -23,9 +28,8 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ recommended: false, }, messages: { - invalidTestId: '`{{attr}}` "{{value}}" should match `{{regex}}`', + consistentDataTestId: '`{{attr}}` "{{value}}" should match `{{regex}}`', }, - fixable: null, schema: [ { type: 'object', @@ -67,7 +71,7 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ function getFileNameData() { const splitPath = getFilename().split('/'); - const fileNameWithExtension = splitPath.pop(); + const fileNameWithExtension = splitPath.pop() ?? ''; const parent = splitPath.pop(); const fileName = fileNameWithExtension.split('.').shift(); @@ -80,17 +84,18 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ return new RegExp(testIdPattern.replace(FILENAME_PLACEHOLDER, fileName)); } - function isTestIdAttribute(name: string) { + function isTestIdAttribute(name: string): boolean { if (typeof attr === 'string') { return attr === name; } else { - return attr.includes(name); + return attr?.includes(name) ?? false; } } return { - [`JSXIdentifier`]: (node: TSESTree.JSXIdentifier) => { + JSXIdentifier: (node) => { if ( + !node.parent || !isJSXAttribute(node.parent) || !isLiteral(node.parent.value) || !isTestIdAttribute(node.name) @@ -100,12 +105,12 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ const value = node.parent.value.value; const { fileName } = getFileNameData(); - const regex = getTestIdValidator(fileName); + const regex = getTestIdValidator(fileName ?? ''); if (value && typeof value === 'string' && !regex.test(value)) { context.report({ node, - messageId: 'invalidTestId', + messageId: 'consistentDataTestId', data: { attr: node.name, value, diff --git a/lib/rules/no-await-sync-events.ts b/lib/rules/no-await-sync-events.ts index 4b1c983a..1e5e4770 100644 --- a/lib/rules/no-await-sync-events.ts +++ b/lib/rules/no-await-sync-events.ts @@ -1,12 +1,20 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl, SYNC_EVENTS } from '../utils'; -import { isObjectExpression, isProperty, isIdentifier } from '../node-utils'; +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; +import { + getDeepestIdentifierNode, + getPropertyIdentifierNode, + isLiteral, + isObjectExpression, + isProperty, +} from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; + export const RULE_NAME = 'no-await-sync-events'; export type MessageIds = 'noAwaitSyncEvents'; type Options = []; -const SYNC_EVENTS_REGEXP = new RegExp(`^(${SYNC_EVENTS.join('|')})$`); -export default ESLintUtils.RuleCreator(getDocsUrl)({ +const USER_EVENT_ASYNC_EXCEPTIONS: string[] = ['type', 'keyboard']; + +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', @@ -16,43 +24,67 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ recommended: 'error', }, messages: { - noAwaitSyncEvents: '`{{ name }}` does not need `await` operator', + noAwaitSyncEvents: + '`{{ name }}` is sync and does not need `await` operator', }, - fixable: null, schema: [], }, defaultOptions: [], - create(context) { - // userEvent.type() is an exception, which returns a - // Promise. But it is only necessary to wait when delay - // option is specified. So this rule has a special exception - // for the case await userEvent.type(element, 'abc', {delay: 1234}) + create(context, _, helpers) { + // userEvent.type() and userEvent.keyboard() are exceptions, which returns a + // Promise. But it is only necessary to wait when delay option other than 0 + // is specified. So this rule has a special exception for the case await: + // - userEvent.type(element, 'abc', {delay: 1234}) + // - userEvent.keyboard('abc', {delay: 1234}) return { - [`AwaitExpression > CallExpression > MemberExpression > Identifier[name=${SYNC_EVENTS_REGEXP}]`]( - node: TSESTree.Identifier - ) { - const memberExpression = node.parent as TSESTree.MemberExpression; - const methodNode = memberExpression.property as TSESTree.Identifier; - const callExpression = memberExpression.parent as TSESTree.CallExpression; - const lastArg = callExpression.arguments[callExpression.arguments.length - 1] - const withDelay = isObjectExpression(lastArg) && + 'AwaitExpression > CallExpression'(node: TSESTree.CallExpression) { + const simulateEventFunctionIdentifier = getDeepestIdentifierNode(node); + + if (!simulateEventFunctionIdentifier) { + return; + } + + const isSimulateEventMethod = + helpers.isUserEventMethod(simulateEventFunctionIdentifier) || + helpers.isFireEventMethod(simulateEventFunctionIdentifier); + + if (!isSimulateEventMethod) { + return; + } + + const lastArg = node.arguments[node.arguments.length - 1]; + + const hasDelay = + isObjectExpression(lastArg) && lastArg.properties.some( - property => - isProperty(property) && - isIdentifier(property.key) && - property.key.name === 'delay' + (property) => + (isProperty(property) && + ASTUtils.isIdentifier(property.key) && + property.key.name === 'delay' && + isLiteral(property.value) && + property.value.value) ?? + 0 > 0 ); - if (!(node.name === 'userEvent' && ['type', 'keyboard'].includes(methodNode.name) && withDelay)) { - context.report({ - node: methodNode, - messageId: 'noAwaitSyncEvents', - data: { - name: `${node.name}.${methodNode.name}`, - }, - }); + const simulateEventFunctionName = simulateEventFunctionIdentifier.name; + + if ( + USER_EVENT_ASYNC_EXCEPTIONS.includes(simulateEventFunctionName) && + hasDelay + ) { + return; } + + context.report({ + node, + messageId: 'noAwaitSyncEvents', + data: { + name: `${ + getPropertyIdentifierNode(node)?.name + }.${simulateEventFunctionName}`, + }, + }); }, }; }, diff --git a/lib/rules/no-await-sync-query.ts b/lib/rules/no-await-sync-query.ts index 7e4efec1..f3604ce9 100644 --- a/lib/rules/no-await-sync-query.ts +++ b/lib/rules/no-await-sync-query.ts @@ -1,13 +1,12 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl } from '../utils'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; +import { getDeepestIdentifierNode } from '../node-utils'; export const RULE_NAME = 'no-await-sync-query'; export type MessageIds = 'noAwaitSyncQuery'; type Options = []; -const SYNC_QUERIES_REGEXP = /^(get|query)(All)?By(LabelText|PlaceholderText|Text|AltText|Title|DisplayValue|Role|TestId)$/; - -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', @@ -17,25 +16,32 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ recommended: 'error', }, messages: { - noAwaitSyncQuery: '`{{ name }}` does not need `await` operator', + noAwaitSyncQuery: + '`{{ name }}` query is sync so it does not need to be awaited', }, - fixable: null, schema: [], }, defaultOptions: [], - create(context) { - const reportError = (node: TSESTree.Identifier) => - context.report({ - node, - messageId: 'noAwaitSyncQuery', - data: { - name: node.name, - }, - }); + create(context, _, helpers) { return { - [`AwaitExpression > CallExpression > Identifier[name=${SYNC_QUERIES_REGEXP}]`]: reportError, - [`AwaitExpression > CallExpression > MemberExpression > Identifier[name=${SYNC_QUERIES_REGEXP}]`]: reportError, + 'AwaitExpression > CallExpression'(node: TSESTree.CallExpression) { + const deepestIdentifierNode = getDeepestIdentifierNode(node); + + if (!deepestIdentifierNode) { + return; + } + + if (helpers.isSyncQuery(deepestIdentifierNode)) { + context.report({ + node: deepestIdentifierNode, + messageId: 'noAwaitSyncQuery', + data: { + name: deepestIdentifierNode.name, + }, + }); + } + }, }; }, }); diff --git a/lib/rules/no-container.ts b/lib/rules/no-container.ts new file mode 100644 index 00000000..781b8762 --- /dev/null +++ b/lib/rules/no-container.ts @@ -0,0 +1,160 @@ +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; +import { + getDeepestIdentifierNode, + getFunctionName, + getInnermostReturningFunction, + isMemberExpression, + isObjectPattern, + isProperty, +} from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; + +export const RULE_NAME = 'no-container'; +export type MessageIds = 'noContainer'; +type Options = []; + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: 'Disallow the use of container methods', + category: 'Best Practices', + recommended: 'error', + }, + messages: { + noContainer: + 'Avoid using container methods. Prefer using the methods from Testing Library, such as "getByRole()"', + }, + schema: [], + }, + defaultOptions: [], + + create(context, [], helpers) { + const destructuredContainerPropNames: string[] = []; + const renderWrapperNames: string[] = []; + let renderResultVarName: string | null = null; + let containerName: string | null = null; + let containerCallsMethod = false; + + function detectRenderWrapper(node: TSESTree.Identifier): void { + const innerFunction = getInnermostReturningFunction(context, node); + + if (innerFunction) { + renderWrapperNames.push(getFunctionName(innerFunction)); + } + } + + function showErrorIfChainedContainerMethod( + innerNode: TSESTree.MemberExpression + ) { + if (isMemberExpression(innerNode)) { + if (ASTUtils.isIdentifier(innerNode.object)) { + const isContainerName = innerNode.object.name === containerName; + + if (isContainerName) { + context.report({ + node: innerNode, + messageId: 'noContainer', + }); + return; + } + + const isRenderWrapper = innerNode.object.name === renderResultVarName; + containerCallsMethod = + ASTUtils.isIdentifier(innerNode.property) && + innerNode.property.name === 'container' && + isRenderWrapper; + + if (containerCallsMethod) { + context.report({ + node: innerNode.property, + messageId: 'noContainer', + }); + return; + } + } + showErrorIfChainedContainerMethod( + innerNode.object as TSESTree.MemberExpression + ); + } + } + + return { + CallExpression(node) { + const callExpressionIdentifier = getDeepestIdentifierNode(node); + + if (!callExpressionIdentifier) { + return; + } + + if (helpers.isRenderUtil(callExpressionIdentifier)) { + detectRenderWrapper(callExpressionIdentifier); + } + + if (isMemberExpression(node.callee)) { + showErrorIfChainedContainerMethod(node.callee); + } else { + ASTUtils.isIdentifier(node.callee) && + destructuredContainerPropNames.includes(node.callee.name) && + context.report({ + node, + messageId: 'noContainer', + }); + } + }, + + VariableDeclarator: function (node) { + if (!node.init) { + return; + } + const initIdentifierNode = getDeepestIdentifierNode(node.init); + + if (!initIdentifierNode) { + return; + } + + const isRenderWrapperVariableDeclarator = initIdentifierNode + ? renderWrapperNames.includes(initIdentifierNode.name) + : false; + + if ( + !helpers.isRenderVariableDeclarator(node) && + !isRenderWrapperVariableDeclarator + ) { + return; + } + + if (isObjectPattern(node.id)) { + const containerIndex = node.id.properties.findIndex( + (property) => + isProperty(property) && + ASTUtils.isIdentifier(property.key) && + property.key.name === 'container' + ); + + const nodeValue = + containerIndex !== -1 && node.id.properties[containerIndex].value; + + if (!nodeValue) { + return; + } + + if (ASTUtils.isIdentifier(nodeValue)) { + containerName = nodeValue.name; + } else { + isObjectPattern(nodeValue) && + nodeValue.properties.forEach( + (property) => + isProperty(property) && + ASTUtils.isIdentifier(property.key) && + destructuredContainerPropNames.push(property.key.name) + ); + } + } else if (ASTUtils.isIdentifier(node.id)) { + renderResultVarName = node.id.name; + } + }, + }; + }, +}); diff --git a/lib/rules/no-debug.ts b/lib/rules/no-debug.ts index 4cc8bbde..5e2222e5 100644 --- a/lib/rules/no-debug.ts +++ b/lib/rules/no-debug.ts @@ -1,53 +1,20 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl, LIBRARY_MODULES } from '../utils'; import { + getDeepestIdentifierNode, + getFunctionName, + getInnermostReturningFunction, + getPropertyIdentifierNode, + getReferenceNode, isObjectPattern, isProperty, - isIdentifier, - isCallExpression, - isLiteral, - isAwaitExpression, - isMemberExpression, - isImportSpecifier, - isRenderFunction, } from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; export const RULE_NAME = 'no-debug'; export type MessageIds = 'noDebug'; -type Options = [{ renderFunctions?: string[] }]; - -function isRenderVariableDeclarator( - node: TSESTree.VariableDeclarator, - renderFunctions: string[] -) { - if (node.init) { - if (isAwaitExpression(node.init)) { - return ( - node.init.argument && - isRenderFunction(node.init.argument as TSESTree.CallExpression, [ - 'render', - ...renderFunctions, - ]) - ); - } else { - return ( - isCallExpression(node.init) && - isRenderFunction(node.init, ['render', ...renderFunctions]) - ); - } - } - - return false; -} - -function hasTestingLibraryImportModule( - importDeclarationNode: TSESTree.ImportDeclaration -) { - const literal = importDeclarationNode.source; - return LIBRARY_MODULES.some(module => module === literal.value); -} +type Options = []; -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', @@ -59,161 +26,107 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ messages: { noDebug: 'Unexpected debug statement', }, - fixable: null, - schema: [ - { - type: 'object', - properties: { - renderFunctions: { - type: 'array', - }, - }, - }, - ], + schema: [], }, - defaultOptions: [ - { - renderFunctions: [], - }, - ], + defaultOptions: [], - create(context, [options]) { - let hasDestructuredDebugStatement = false; - const renderVariableDeclarators: TSESTree.VariableDeclarator[] = []; + create(context, [], helpers) { + const suspiciousDebugVariableNames: string[] = []; + const suspiciousReferenceNodes: TSESTree.Identifier[] = []; + const renderWrapperNames: string[] = []; - const { renderFunctions } = options; + function detectRenderWrapper(node: TSESTree.Identifier): void { + const innerFunction = getInnermostReturningFunction(context, node); - let hasImportedScreen = false; - let wildcardImportName: string = null; + if (innerFunction) { + renderWrapperNames.push(getFunctionName(innerFunction)); + } + } return { VariableDeclarator(node) { - if (isRenderVariableDeclarator(node, renderFunctions)) { - if ( - isObjectPattern(node.id) && - node.id.properties.some( - property => - isProperty(property) && - isIdentifier(property.key) && - property.key.name === 'debug' - ) - ) { - hasDestructuredDebugStatement = true; - } + if (!node.init) { + return; + } + const initIdentifierNode = getDeepestIdentifierNode(node.init); + + if (!initIdentifierNode) { + return; + } - if (node.id.type === 'Identifier') { - renderVariableDeclarators.push(node); + const isRenderWrapperVariableDeclarator = initIdentifierNode + ? renderWrapperNames.includes(initIdentifierNode.name) + : false; + + if ( + !helpers.isRenderVariableDeclarator(node) && + !isRenderWrapperVariableDeclarator + ) { + return; + } + + // find debug obtained from render and save their name, like: + // const { debug } = render(); + if (isObjectPattern(node.id)) { + for (const property of node.id.properties) { + if ( + isProperty(property) && + ASTUtils.isIdentifier(property.key) && + property.key.name === 'debug' + ) { + const identifierNode = getDeepestIdentifierNode(property.value); + + if (identifierNode) { + suspiciousDebugVariableNames.push(identifierNode.name); + } + } } } + + // find utils kept from render and save their node, like: + // const utils = render(); + if (ASTUtils.isIdentifier(node.id)) { + suspiciousReferenceNodes.push(node.id); + } }, - [`VariableDeclarator > CallExpression > Identifier[name="require"]`]( - node: TSESTree.Identifier - ) { - const { arguments: args } = node.parent as TSESTree.CallExpression; - - const literalNodeScreenModuleName = args.find( - args => - isLiteral(args) && - typeof args.value === 'string' && - LIBRARY_MODULES.includes(args.value) - ); + CallExpression(node) { + const callExpressionIdentifier = getDeepestIdentifierNode(node); - if (!literalNodeScreenModuleName) { + if (!callExpressionIdentifier) { return; } - const declaratorNode = node.parent - .parent as TSESTree.VariableDeclarator; + if (helpers.isRenderUtil(callExpressionIdentifier)) { + detectRenderWrapper(callExpressionIdentifier); + } - hasImportedScreen = - isObjectPattern(declaratorNode.id) && - declaratorNode.id.properties.some( - property => - isProperty(property) && - isIdentifier(property.key) && - property.key.name === 'screen' - ); - }, - // checks if import has shape: - // import { screen } from '@testing-library/dom'; - ImportDeclaration(node: TSESTree.ImportDeclaration) { - if (!hasTestingLibraryImportModule(node)) return; - hasImportedScreen = node.specifiers.some( - s => isImportSpecifier(s) && s.imported.name === 'screen' - ); - }, - // checks if import has shape: - // import * as dtl from '@testing-library/dom'; - 'ImportDeclaration ImportNamespaceSpecifier'( - node: TSESTree.ImportNamespaceSpecifier - ) { - const importDeclarationNode = node.parent as TSESTree.ImportDeclaration; - if (!hasTestingLibraryImportModule(importDeclarationNode)) return; - - wildcardImportName = node.local && node.local.name; - }, - [`CallExpression > Identifier[name="debug"]`](node: TSESTree.Identifier) { - if (hasDestructuredDebugStatement) { - context.report({ - node, - messageId: 'noDebug', - }); + const referenceNode = getReferenceNode(node); + const referenceIdentifier = getPropertyIdentifierNode(referenceNode); + + if (!referenceIdentifier) { + return; } - }, - [`CallExpression > MemberExpression > Identifier[name="debug"]`]( - node: TSESTree.Identifier - ) { - const memberExpression = node.parent as TSESTree.MemberExpression; - const identifier = memberExpression.object as TSESTree.Identifier; - const memberExpressionName = identifier.name; - /* - check if `debug` used following the pattern: - - import { screen } from '@testing-library/dom'; - ... - screen.debug(); - */ - const isScreenDebugUsed = - hasImportedScreen && memberExpressionName === 'screen'; - - /* - check if `debug` used following the pattern: - - import * as dtl from '@testing-library/dom'; - ... - dtl.debug(); - */ - const isNamespaceDebugUsed = - wildcardImportName && memberExpressionName === wildcardImportName; - - if (isScreenDebugUsed || isNamespaceDebugUsed) { + + const isDebugUtil = helpers.isDebugUtil(callExpressionIdentifier); + const isDeclaredDebugVariable = suspiciousDebugVariableNames.includes( + callExpressionIdentifier.name + ); + const isChainedReferenceDebug = suspiciousReferenceNodes.some( + (suspiciousReferenceIdentifier) => { + return ( + callExpressionIdentifier.name === 'debug' && + suspiciousReferenceIdentifier.name === referenceIdentifier.name + ); + } + ); + + if (isDebugUtil || isDeclaredDebugVariable || isChainedReferenceDebug) { context.report({ - node, + node: callExpressionIdentifier, messageId: 'noDebug', }); } }, - 'Program:exit'() { - renderVariableDeclarators.forEach(renderVar => { - const renderVarReferences = context - .getDeclaredVariables(renderVar)[0] - .references.slice(1); - renderVarReferences.forEach(ref => { - const parent = ref.identifier.parent; - if ( - isMemberExpression(parent) && - isIdentifier(parent.property) && - parent.property.name === 'debug' && - isCallExpression(parent.parent) - ) { - context.report({ - node: parent.property, - messageId: 'noDebug', - }); - } - }); - }); - }, }; }, }); diff --git a/lib/rules/no-dom-import.ts b/lib/rules/no-dom-import.ts index f104d134..1246447d 100644 --- a/lib/rules/no-dom-import.ts +++ b/lib/rules/no-dom-import.ts @@ -1,6 +1,6 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl } from '../utils'; -import { isLiteral, isIdentifier } from '../node-utils'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; +import { isCallExpression } from '../node-utils'; export const RULE_NAME = 'no-dom-import'; export type MessageIds = 'noDomImport' | 'noDomImportFramework'; @@ -11,7 +11,7 @@ const DOM_TESTING_LIBRARY_MODULES = [ '@testing-library/dom', ]; -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', @@ -35,13 +35,12 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ }, defaultOptions: [''], - create(context, [framework]) { + create(context, [framework], helpers) { function report( - node: TSESTree.ImportDeclaration | TSESTree.Identifier, + node: TSESTree.ImportDeclaration | TSESTree.CallExpression, moduleName: string ) { if (framework) { - const isRequire = isIdentifier(node) && node.name === 'require'; const correctModuleName = moduleName.replace('dom', framework); context.report({ node, @@ -50,9 +49,8 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ module: correctModuleName, }, fix(fixer) { - if (isRequire) { - const callExpression = node.parent as TSESTree.CallExpression; - const name = callExpression.arguments[0] as TSESTree.Literal; + if (isCallExpression(node)) { + const name = node.arguments[0] as TSESTree.Literal; // Replace the module name with the raw module name as we can't predict which punctuation the user is going to use return fixer.replaceText( @@ -60,8 +58,7 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ name.raw.replace(moduleName, correctModuleName) ); } else { - const importDeclaration = node as TSESTree.ImportDeclaration; - const name = importDeclaration.source; + const name = node.source; return fixer.replaceText( name, name.raw.replace(moduleName, correctModuleName) @@ -76,34 +73,25 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ }); } } + return { - ImportDeclaration(node) { - const value = node.source.value; - const domModuleName = DOM_TESTING_LIBRARY_MODULES.find( - module => module === value - ); + 'Program:exit'() { + const importName = helpers.getTestingLibraryImportName(); + const importNode = helpers.getTestingLibraryImportNode(); - if (domModuleName) { - report(node, domModuleName); + if (!importNode) { + return; } - }, - [`CallExpression > Identifier[name="require"]`]( - node: TSESTree.Identifier - ) { - const callExpression = node.parent as TSESTree.CallExpression; - const { arguments: args } = callExpression; - - const literalNodeDomModuleName = args.find( - args => - isLiteral(args) && - typeof args.value === 'string' && - DOM_TESTING_LIBRARY_MODULES.includes(args.value) - ) as TSESTree.Literal; + const domModuleName = DOM_TESTING_LIBRARY_MODULES.find( + (module) => module === importName + ); - if (literalNodeDomModuleName) { - report(node, literalNodeDomModuleName.value as string); + if (!domModuleName) { + return; } + + report(importNode, domModuleName); }, }; }, diff --git a/lib/rules/no-manual-cleanup.ts b/lib/rules/no-manual-cleanup.ts index 6907f56b..a49a9836 100644 --- a/lib/rules/no-manual-cleanup.ts +++ b/lib/rules/no-manual-cleanup.ts @@ -1,22 +1,27 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl } from '../utils'; import { + ASTUtils, + TSESTree, + TSESLint, +} from '@typescript-eslint/experimental-utils'; +import { + getVariableReferences, isImportDefaultSpecifier, - isLiteral, - isIdentifier, + isImportSpecifier, + isMemberExpression, isObjectPattern, isProperty, - isMemberExpression, - isImportSpecifier, + ImportModuleNode, + isImportDeclaration, } from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'no-manual-cleanup'; export type MessageIds = 'noManualCleanup'; type Options = []; -const CLEANUP_LIBRARY_REGEX = /(@testing-library\/(preact|react|svelte|vue))|@marko\/testing-library/; +const CLEANUP_LIBRARY_REGEXP = /(@testing-library\/(preact|react|svelte|vue))|@marko\/testing-library/; -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', @@ -29,55 +34,42 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ noManualCleanup: "`cleanup` is performed automatically by your test runner, you don't need manual cleanups.", }, - fixable: null, schema: [], }, defaultOptions: [], - create(context) { - let defaultImportFromTestingLibrary: TSESTree.ImportDeclaration; - let defaultRequireFromTestingLibrary: - | TSESTree.Identifier - | TSESTree.ArrayPattern; - - // can't find the right type? - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function reportImportReferences(references: any[]) { - if (references && references.length > 0) { - references.forEach(reference => { - const utilsUsage = reference.identifier.parent; - if ( - isMemberExpression(utilsUsage) && - isIdentifier(utilsUsage.property) && - utilsUsage.property.name === 'cleanup' - ) { - context.report({ - node: utilsUsage.property, - messageId: 'noManualCleanup', - }); - } - }); + create(context, _, helpers) { + function reportImportReferences(references: TSESLint.Scope.Reference[]) { + for (const reference of references) { + const utilsUsage = reference.identifier.parent; + + if ( + utilsUsage && + isMemberExpression(utilsUsage) && + ASTUtils.isIdentifier(utilsUsage.property) && + utilsUsage.property.name === 'cleanup' + ) { + context.report({ + node: utilsUsage.property, + messageId: 'noManualCleanup', + }); + } } } - return { - ImportDeclaration(node) { - const value = node.source.value as string; - const testingLibraryWithCleanup = value.match(CLEANUP_LIBRARY_REGEX); - - // Early return if the library doesn't support `cleanup` - if (!testingLibraryWithCleanup) { - return; - } + function reportCandidateModule(moduleNode: ImportModuleNode) { + if (isImportDeclaration(moduleNode)) { + // case: import utils from 'testing-library-module' + if (isImportDefaultSpecifier(moduleNode.specifiers[0])) { + const { references } = context.getDeclaredVariables(moduleNode)[0]; - if (isImportDefaultSpecifier(node.specifiers[0])) { - defaultImportFromTestingLibrary = node; + reportImportReferences(references); } - const cleanupSpecifier = node.specifiers.find( - specifier => + // case: import { cleanup } from 'testing-library-module' + const cleanupSpecifier = moduleNode.specifiers.find( + (specifier) => isImportSpecifier(specifier) && - specifier.imported && specifier.imported.name === 'cleanup' ); @@ -87,31 +79,15 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ messageId: 'noManualCleanup', }); } - }, - [`VariableDeclarator > CallExpression > Identifier[name="require"]`]( - node: TSESTree.Identifier - ) { - const { arguments: args } = node.parent as TSESTree.CallExpression; - - const literalNodeCleanupModuleName = args.find( - args => - isLiteral(args) && - typeof args.value === 'string' && - args.value.match(CLEANUP_LIBRARY_REGEX) - ); - - if (!literalNodeCleanupModuleName) { - return; - } - - const declaratorNode = node.parent - .parent as TSESTree.VariableDeclarator; + } else { + const declaratorNode = moduleNode.parent as TSESTree.VariableDeclarator; if (isObjectPattern(declaratorNode.id)) { + // case: const { cleanup } = require('testing-library-module') const cleanupProperty = declaratorNode.id.properties.find( - property => + (property) => isProperty(property) && - isIdentifier(property.key) && + ASTUtils.isIdentifier(property.key) && property.key.name === 'cleanup' ); @@ -122,24 +98,29 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ }); } } else { - defaultRequireFromTestingLibrary = declaratorNode.id; - } - }, - 'Program:exit'() { - if (defaultImportFromTestingLibrary) { - const references = context.getDeclaredVariables( - defaultImportFromTestingLibrary - )[0].references; - + // case: const utils = require('testing-library-module') + const references = getVariableReferences(context, declaratorNode); reportImportReferences(references); } + } + } - if (defaultRequireFromTestingLibrary) { - const references = context - .getDeclaredVariables(defaultRequireFromTestingLibrary.parent)[0] - .references.slice(1); + return { + 'Program:exit'() { + const testingLibraryImportName = helpers.getTestingLibraryImportName(); + const testingLibraryImportNode = helpers.getTestingLibraryImportNode(); + const customModuleImportNode = helpers.getCustomModuleImportNode(); + + if ( + testingLibraryImportName && + testingLibraryImportNode && + testingLibraryImportName.match(CLEANUP_LIBRARY_REGEXP) + ) { + reportCandidateModule(testingLibraryImportNode); + } - reportImportReferences(references); + if (customModuleImportNode) { + reportCandidateModule(customModuleImportNode); } }, }; diff --git a/lib/rules/no-node-access.ts b/lib/rules/no-node-access.ts new file mode 100644 index 00000000..ca3acf59 --- /dev/null +++ b/lib/rules/no-node-access.ts @@ -0,0 +1,42 @@ +import { TSESTree, ASTUtils } from '@typescript-eslint/experimental-utils'; +import { ALL_RETURNING_NODES } from '../utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; + +export const RULE_NAME = 'no-node-access'; +export type MessageIds = 'noNodeAccess'; +type Options = []; + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: 'Disallow direct Node access', + category: 'Best Practices', + recommended: 'error', + }, + messages: { + noNodeAccess: + 'Avoid direct Node access. Prefer using the methods from Testing Library.', + }, + schema: [], + }, + defaultOptions: [], + + create(context) { + function showErrorForNodeAccess(node: TSESTree.MemberExpression) { + ASTUtils.isIdentifier(node.property) && + ALL_RETURNING_NODES.includes(node.property.name) && + context.report({ + node: node, + loc: node.property.loc.start, + messageId: 'noNodeAccess', + }); + } + + return { + ['ExpressionStatement MemberExpression']: showErrorForNodeAccess, + ['VariableDeclarator MemberExpression']: showErrorForNodeAccess, + }; + }, +}); diff --git a/lib/rules/no-promise-in-fire-event.ts b/lib/rules/no-promise-in-fire-event.ts new file mode 100644 index 00000000..4a765e6b --- /dev/null +++ b/lib/rules/no-promise-in-fire-event.ts @@ -0,0 +1,105 @@ +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; +import { + findClosestCallExpressionNode, + getDeepestIdentifierNode, + isCallExpression, + isNewExpression, + isPromiseIdentifier, +} from '../node-utils'; + +export const RULE_NAME = 'no-promise-in-fire-event'; +export type MessageIds = 'noPromiseInFireEvent'; +type Options = []; + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: + 'Disallow the use of promises passed to a `fireEvent` method', + category: 'Best Practices', + recommended: false, + }, + messages: { + noPromiseInFireEvent: + "A promise shouldn't be passed to a `fireEvent` method, instead pass the DOM element", + }, + schema: [], + }, + defaultOptions: [], + + create(context, _, helpers) { + function checkSuspiciousNode( + node: TSESTree.Node, + originalNode?: TSESTree.Node + ): void { + if (ASTUtils.isAwaitExpression(node)) { + return; + } + + if (isNewExpression(node)) { + if (isPromiseIdentifier(node.callee)) { + return context.report({ + node: originalNode ?? node, + messageId: 'noPromiseInFireEvent', + }); + } + } + + if (isCallExpression(node)) { + const domElementIdentifier = getDeepestIdentifierNode(node); + + if (!domElementIdentifier) { + return; + } + + if ( + helpers.isAsyncQuery(domElementIdentifier) || + isPromiseIdentifier(domElementIdentifier) + ) { + return context.report({ + node: originalNode ?? node, + messageId: 'noPromiseInFireEvent', + }); + } + } + + if (ASTUtils.isIdentifier(node)) { + const nodeVariable = ASTUtils.findVariable( + context.getScope(), + node.name + ); + if (!nodeVariable || !nodeVariable.defs) { + return; + } + + for (const definition of nodeVariable.defs) { + const variableDeclarator = definition.node as TSESTree.VariableDeclarator; + if (variableDeclarator.init) { + checkSuspiciousNode(variableDeclarator.init, node); + } + } + } + } + + return { + 'CallExpression Identifier'(node: TSESTree.Identifier) { + if (!helpers.isFireEventMethod(node)) { + return; + } + + const closestCallExpression = findClosestCallExpressionNode(node, true); + + if (!closestCallExpression) { + return; + } + + const domElementArgument = closestCallExpression.arguments[0]; + + checkSuspiciousNode(domElementArgument); + }, + }; + }, +}); diff --git a/lib/rules/no-render-in-setup.ts b/lib/rules/no-render-in-setup.ts index 2d35beba..37f96f5b 100644 --- a/lib/rules/no-render-in-setup.ts +++ b/lib/rules/no-render-in-setup.ts @@ -1,21 +1,18 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl, TESTING_FRAMEWORK_SETUP_HOOKS } from '../utils'; +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; +import { TESTING_FRAMEWORK_SETUP_HOOKS } from '../utils'; import { - isLiteral, - isProperty, - isIdentifier, - isObjectPattern, + getDeepestIdentifierNode, + getFunctionName, + getInnermostReturningFunction, isCallExpression, - isRenderFunction, - isImportSpecifier, } from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'no-render-in-setup'; export type MessageIds = 'noRenderInSetup'; type Options = [ { allowTestingFrameworkSetupHook?: string; - renderFunctions?: string[]; } ]; @@ -23,138 +20,112 @@ export function findClosestBeforeHook( node: TSESTree.Node, testingFrameworkSetupHooksToFilter: string[] ): TSESTree.Identifier | null { - if (node === null) return null; + if (node === null) { + return null; + } + if ( isCallExpression(node) && - isIdentifier(node.callee) && + ASTUtils.isIdentifier(node.callee) && testingFrameworkSetupHooksToFilter.includes(node.callee.name) ) { return node.callee; } - return findClosestBeforeHook(node.parent, testingFrameworkSetupHooksToFilter); + if (node.parent) { + return findClosestBeforeHook( + node.parent, + testingFrameworkSetupHooksToFilter + ); + } + + return null; } -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', docs: { - description: 'Disallow the use of `render` in setup functions', + description: + 'Disallow the use of `render` in testing frameworks setup functions', category: 'Best Practices', recommended: false, }, messages: { noRenderInSetup: - 'Move `render` out of `{{name}}` and into individual tests.', + 'Forbidden usage of `render` within testing framework `{{ name }}` setup', }, - fixable: null, schema: [ { type: 'object', properties: { - renderFunctions: { - type: 'array', - }, allowTestingFrameworkSetupHook: { enum: TESTING_FRAMEWORK_SETUP_HOOKS, }, }, - anyOf: [ - { - required: ['renderFunctions'], - }, - { - required: ['allowTestingFrameworkSetupHook'], - }, - ], }, ], }, defaultOptions: [ { - renderFunctions: [], allowTestingFrameworkSetupHook: '', }, ], - create(context, [{ renderFunctions, allowTestingFrameworkSetupHook }]) { - let renderImportedFromTestingLib = false; - let wildcardImportName: string | null = null; + create(context, [{ allowTestingFrameworkSetupHook }], helpers) { + const renderWrapperNames: string[] = []; + + function detectRenderWrapper(node: TSESTree.Identifier): void { + const innerFunction = getInnermostReturningFunction(context, node); + + if (innerFunction) { + renderWrapperNames.push(getFunctionName(innerFunction)); + } + } return { - // checks if import has shape: - // import * as dtl from '@testing-library/dom'; - 'ImportDeclaration[source.value=/testing-library/] ImportNamespaceSpecifier'( - node: TSESTree.ImportNamespaceSpecifier - ) { - wildcardImportName = node.local && node.local.name; - }, - // checks if `render` is imported from a '@testing-library/foo' - 'ImportDeclaration[source.value=/testing-library/]'( - node: TSESTree.ImportDeclaration - ) { - renderImportedFromTestingLib = node.specifiers.some(specifier => { - return ( - isImportSpecifier(specifier) && specifier.local.name === 'render' - ); - }); - }, - [`VariableDeclarator > CallExpression > Identifier[name="require"]`]( - node: TSESTree.Identifier - ) { - const { - arguments: callExpressionArgs, - } = node.parent as TSESTree.CallExpression; - const testingLibImport = callExpressionArgs.find( - args => - isLiteral(args) && - typeof args.value === 'string' && - RegExp(/testing-library/, 'g').test(args.value) + CallExpression(node) { + const testingFrameworkSetupHooksToFilter = TESTING_FRAMEWORK_SETUP_HOOKS.filter( + (hook) => hook !== allowTestingFrameworkSetupHook ); - if (!testingLibImport) { + const callExpressionIdentifier = getDeepestIdentifierNode(node); + + if (!callExpressionIdentifier) { return; } - const declaratorNode = node.parent - .parent as TSESTree.VariableDeclarator; - - renderImportedFromTestingLib = - isObjectPattern(declaratorNode.id) && - declaratorNode.id.properties.some( - property => - isProperty(property) && - isIdentifier(property.key) && - property.key.name === 'render' - ); - }, - CallExpression(node) { - let testingFrameworkSetupHooksToFilter = TESTING_FRAMEWORK_SETUP_HOOKS; - if (allowTestingFrameworkSetupHook.length !== 0) { - testingFrameworkSetupHooksToFilter = TESTING_FRAMEWORK_SETUP_HOOKS.filter( - hook => hook !== allowTestingFrameworkSetupHook - ); + + const isRenderIdentifier = helpers.isRenderUtil( + callExpressionIdentifier + ); + + if (isRenderIdentifier) { + detectRenderWrapper(callExpressionIdentifier); + } + + if ( + !isRenderIdentifier && + !renderWrapperNames.includes(callExpressionIdentifier.name) + ) { + return; } + const beforeHook = findClosestBeforeHook( node, testingFrameworkSetupHooksToFilter ); - // if `render` is imported from a @testing-library/foo or - // imported with a wildcard, add `render` to the list of - // disallowed render functions - const disallowedRenderFns = - renderImportedFromTestingLib || wildcardImportName - ? ['render', ...renderFunctions] - : renderFunctions; - - if (isRenderFunction(node, disallowedRenderFns) && beforeHook) { - context.report({ - node, - messageId: 'noRenderInSetup', - data: { - name: beforeHook.name, - }, - }); + + if (!beforeHook) { + return; } + + context.report({ + node: callExpressionIdentifier, + messageId: 'noRenderInSetup', + data: { + name: beforeHook.name, + }, + }); }, }; }, diff --git a/lib/rules/no-wait-for-empty-callback.ts b/lib/rules/no-wait-for-empty-callback.ts index 72c5225f..04a622a4 100644 --- a/lib/rules/no-wait-for-empty-callback.ts +++ b/lib/rules/no-wait-for-empty-callback.ts @@ -1,19 +1,16 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl } from '../utils'; +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; import { + getPropertyIdentifierNode, isBlockStatement, isCallExpression, - isIdentifier, } from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'no-wait-for-empty-callback'; export type MessageIds = 'noWaitForEmptyCallback'; type Options = []; -const WAIT_EXPRESSION_QUERY = - 'CallExpression[callee.name=/^(waitFor|waitForElementToBeRemoved)$/]'; - -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'suggestion', @@ -21,28 +18,44 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ description: "It's preferred to avoid empty callbacks in `waitFor` and `waitForElementToBeRemoved`", category: 'Best Practices', - recommended: false, + recommended: 'error', }, messages: { noWaitForEmptyCallback: 'Avoid passing empty callback to `{{ methodName }}`. Insert an assertion instead.', }, - fixable: null, schema: [], }, defaultOptions: [], // trimmed down implementation of https://github.com/eslint/eslint/blob/master/lib/rules/no-empty-function.js - // TODO: var referencing any of previously mentioned? - create: function(context) { + create(context, _, helpers) { + function isValidWaitFor(node: TSESTree.Node): boolean { + const parentCallExpression = node.parent as TSESTree.CallExpression; + const parentIdentifier = getPropertyIdentifierNode(parentCallExpression); + + if (!parentIdentifier) { + return false; + } + + return helpers.isAsyncUtil(parentIdentifier, [ + 'waitFor', + 'waitForElementToBeRemoved', + ]); + } + function reportIfEmpty( node: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression ) { + if (!isValidWaitFor(node)) { + return; + } + if ( isBlockStatement(node.body) && node.body.body.length === 0 && isCallExpression(node.parent) && - isIdentifier(node.parent.callee) + ASTUtils.isIdentifier(node.parent.callee) ) { context.report({ node, @@ -56,17 +69,27 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ } function reportNoop(node: TSESTree.Identifier) { + if (!isValidWaitFor(node)) { + return; + } + context.report({ node, loc: node.loc.start, messageId: 'noWaitForEmptyCallback', + data: { + methodName: + isCallExpression(node.parent) && + ASTUtils.isIdentifier(node.parent.callee) && + node.parent.callee.name, + }, }); } return { - [`${WAIT_EXPRESSION_QUERY} > ArrowFunctionExpression`]: reportIfEmpty, - [`${WAIT_EXPRESSION_QUERY} > FunctionExpression`]: reportIfEmpty, - [`${WAIT_EXPRESSION_QUERY} > Identifier[name="noop"]`]: reportNoop, + 'CallExpression > ArrowFunctionExpression': reportIfEmpty, + 'CallExpression > FunctionExpression': reportIfEmpty, + 'CallExpression > Identifier[name="noop"]': reportNoop, }; }, }); diff --git a/lib/rules/no-wait-for-multiple-assertions.ts b/lib/rules/no-wait-for-multiple-assertions.ts new file mode 100644 index 00000000..aff2f943 --- /dev/null +++ b/lib/rules/no-wait-for-multiple-assertions.ts @@ -0,0 +1,84 @@ +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { + getPropertyIdentifierNode, + isExpressionStatement, +} from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; + +export const RULE_NAME = 'no-wait-for-multiple-assertions'; +export type MessageIds = 'noWaitForMultipleAssertion'; +type Options = []; + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: "It's preferred to avoid multiple assertions in `waitFor`", + category: 'Best Practices', + recommended: false, + }, + messages: { + noWaitForMultipleAssertion: + 'Avoid using multiple assertions within `waitFor` callback', + }, + schema: [], + }, + defaultOptions: [], + create: function (context, _, helpers) { + function getExpectNodes( + body: Array + ): Array { + return body.filter((node) => { + if (!isExpressionStatement(node)) { + return false; + } + + const expressionIdentifier = getPropertyIdentifierNode(node); + if (!expressionIdentifier) { + return false; + } + + return expressionIdentifier.name === 'expect'; + }) as Array; + } + + function reportMultipleAssertion(node: TSESTree.BlockStatement) { + if (!node.parent) { + return; + } + const callExpressionNode = node.parent.parent as TSESTree.CallExpression; + const callExpressionIdentifier = getPropertyIdentifierNode( + callExpressionNode + ); + + if (!callExpressionIdentifier) { + return; + } + + if (!helpers.isAsyncUtil(callExpressionIdentifier, ['waitFor'])) { + return; + } + + const expectNodes = getExpectNodes(node.body); + + if (expectNodes.length <= 1) { + return; + } + + for (let i = 0; i < expectNodes.length; i++) { + if (i !== 0) { + context.report({ + node: expectNodes[i], + messageId: 'noWaitForMultipleAssertion', + }); + } + } + } + + return { + 'CallExpression > ArrowFunctionExpression > BlockStatement': reportMultipleAssertion, + 'CallExpression > FunctionExpression > BlockStatement': reportMultipleAssertion, + }; + }, +}); diff --git a/lib/rules/no-wait-for-side-effects.ts b/lib/rules/no-wait-for-side-effects.ts new file mode 100644 index 00000000..10dcf5c8 --- /dev/null +++ b/lib/rules/no-wait-for-side-effects.ts @@ -0,0 +1,84 @@ +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { + getPropertyIdentifierNode, + isExpressionStatement, +} from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; + +export const RULE_NAME = 'no-wait-for-side-effects'; +export type MessageIds = 'noSideEffectsWaitFor'; +type Options = []; + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: "It's preferred to avoid side effects in `waitFor`", + category: 'Best Practices', + recommended: false, + }, + messages: { + noSideEffectsWaitFor: + 'Avoid using side effects within `waitFor` callback', + }, + schema: [], + }, + defaultOptions: [], + create: function (context, _, helpers) { + function getSideEffectNodes( + body: TSESTree.Node[] + ): TSESTree.ExpressionStatement[] { + return body.filter((node) => { + if (!isExpressionStatement(node)) { + return false; + } + + const expressionIdentifier = getPropertyIdentifierNode(node); + if (!expressionIdentifier) { + return false; + } + + return ( + helpers.isFireEventUtil(expressionIdentifier) || + helpers.isUserEventUtil(expressionIdentifier) + ); + }) as TSESTree.ExpressionStatement[]; + } + + function reportSideEffects(node: TSESTree.BlockStatement) { + if (!node.parent) { + return; + } + const callExpressionNode = node.parent.parent as TSESTree.CallExpression; + const callExpressionIdentifier = getPropertyIdentifierNode( + callExpressionNode + ); + + if (!callExpressionIdentifier) { + return; + } + + if (!helpers.isAsyncUtil(callExpressionIdentifier, ['waitFor'])) { + return; + } + + const sideEffectNodes = getSideEffectNodes(node.body); + if (sideEffectNodes.length === 0) { + return; + } + + for (const sideEffectNode of sideEffectNodes) { + context.report({ + node: sideEffectNode, + messageId: 'noSideEffectsWaitFor', + }); + } + } + + return { + 'CallExpression > ArrowFunctionExpression > BlockStatement': reportSideEffects, + 'CallExpression > FunctionExpression > BlockStatement': reportSideEffects, + }; + }, +}); diff --git a/lib/rules/no-wait-for-snapshot.ts b/lib/rules/no-wait-for-snapshot.ts index 16235ac4..7637748a 100644 --- a/lib/rules/no-wait-for-snapshot.ts +++ b/lib/rules/no-wait-for-snapshot.ts @@ -1,5 +1,5 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl, ASYNC_UTILS, LIBRARY_MODULES } from '../utils'; +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; import { findClosestCallExpressionNode, isMemberExpression, @@ -9,10 +9,9 @@ export const RULE_NAME = 'no-wait-for-snapshot'; export type MessageIds = 'noWaitForSnapshot'; type Options = []; -const ASYNC_UTILS_REGEXP = new RegExp(`^(${ASYNC_UTILS.join('|')})$`); const SNAPSHOT_REGEXP = /^(toMatchSnapshot|toMatchInlineSnapshot)$/; -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', @@ -26,105 +25,52 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ noWaitForSnapshot: "A snapshot can't be generated inside of a `{{ name }}` call", }, - fixable: null, schema: [], }, defaultOptions: [], - create(context) { - const asyncUtilsUsage: Array<{ - node: TSESTree.Identifier | TSESTree.MemberExpression; - name: string; - }> = []; - const importedAsyncUtils: string[] = []; - const snapshotUsage: TSESTree.Identifier[] = []; + create(context, _, helpers) { + function getClosestAsyncUtil( + node: TSESTree.Node + ): TSESTree.Identifier | null { + let n: TSESTree.Node | null = node; + do { + const callExpression = findClosestCallExpressionNode(n); - return { - 'ImportDeclaration > ImportSpecifier,ImportNamespaceSpecifier'( - node: TSESTree.Node - ) { - const parent = node.parent as TSESTree.ImportDeclaration; - - if (!LIBRARY_MODULES.includes(parent.source.value.toString())) return; - - let name; - if (node.type === 'ImportSpecifier') { - name = node.imported.name; + if (!callExpression) { + return null; } - if (node.type === 'ImportNamespaceSpecifier') { - name = node.local.name; + if ( + ASTUtils.isIdentifier(callExpression.callee) && + helpers.isAsyncUtil(callExpression.callee) + ) { + return callExpression.callee; } - - importedAsyncUtils.push(name); - }, - [`CallExpression > Identifier[name=${ASYNC_UTILS_REGEXP}]`]( - node: TSESTree.Identifier - ) { - asyncUtilsUsage.push({ node, name: node.name }); - }, - [`CallExpression > MemberExpression > Identifier[name=${ASYNC_UTILS_REGEXP}]`]( - node: TSESTree.Identifier - ) { - const memberExpression = node.parent as TSESTree.MemberExpression; - const identifier = memberExpression.object as TSESTree.Identifier; - const memberExpressionName = identifier.name; - - asyncUtilsUsage.push({ - node: memberExpression, - name: memberExpressionName, - }); - }, - [`Identifier[name=${SNAPSHOT_REGEXP}]`](node: TSESTree.Identifier) { - snapshotUsage.push(node); - }, - 'Program:exit'() { - const testingLibraryUtilUsage = asyncUtilsUsage.filter(usage => { - if (isMemberExpression(usage.node)) { - const object = usage.node.object as TSESTree.Identifier; - - return importedAsyncUtils.includes(object.name); - } - - return importedAsyncUtils.includes(usage.name); - }); - - function getClosestAsyncUtil( - asyncUtilUsage: { - node: TSESTree.Identifier | TSESTree.MemberExpression; - name: string; - }, - node: TSESTree.Node + if ( + isMemberExpression(callExpression.callee) && + ASTUtils.isIdentifier(callExpression.callee.property) && + helpers.isAsyncUtil(callExpression.callee.property) ) { - let callExpression = findClosestCallExpressionNode(node); - while (callExpression != null) { - if (callExpression.callee === asyncUtilUsage.node) - return asyncUtilUsage; - callExpression = findClosestCallExpressionNode( - callExpression.parent - ); - } - return null; + return callExpression.callee.property; + } + if (callExpression.parent) { + n = findClosestCallExpressionNode(callExpression.parent); } + } while (n !== null); + return null; + } - snapshotUsage.forEach(node => { - testingLibraryUtilUsage.forEach(asyncUtilUsage => { - const closestAsyncUtil = getClosestAsyncUtil(asyncUtilUsage, node); - if (closestAsyncUtil != null) { - let name; - if (isMemberExpression(closestAsyncUtil.node)) { - name = (closestAsyncUtil.node.property as TSESTree.Identifier) - .name; - } else { - name = closestAsyncUtil.name; - } - context.report({ - node, - messageId: 'noWaitForSnapshot', - data: { name }, - }); - } - }); + return { + [`Identifier[name=${SNAPSHOT_REGEXP}]`](node: TSESTree.Identifier) { + const closestAsyncUtil = getClosestAsyncUtil(node); + if (closestAsyncUtil === null) { + return; + } + context.report({ + node, + messageId: 'noWaitForSnapshot', + data: { name: closestAsyncUtil.name }, }); }, }; diff --git a/lib/rules/prefer-explicit-assert.ts b/lib/rules/prefer-explicit-assert.ts index bb9f6614..209c9a88 100644 --- a/lib/rules/prefer-explicit-assert.ts +++ b/lib/rules/prefer-explicit-assert.ts @@ -1,15 +1,8 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { - getDocsUrl, - ALL_QUERIES_METHODS, - PRESENCE_MATCHERS, - ABSENCE_MATCHERS, -} from '../utils'; -import { - findClosestCallNode, - isIdentifier, - isMemberExpression, -} from '../node-utils'; +import { TSESTree, ASTUtils } from '@typescript-eslint/experimental-utils'; +import { PRESENCE_MATCHERS, ABSENCE_MATCHERS } from '../utils'; +import { findClosestCallNode, isMemberExpression } from '../node-utils'; + +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'prefer-explicit-assert'; export type MessageIds = @@ -18,22 +11,13 @@ export type MessageIds = type Options = [ { assertion?: string; - customQueryNames?: string[]; } ]; -const ALL_GET_BY_QUERIES = ALL_QUERIES_METHODS.map( - queryMethod => `get${queryMethod}` -); - -const isValidQuery = (node: TSESTree.Identifier, customQueryNames: string[]) => - ALL_GET_BY_QUERIES.includes(node.name) || - customQueryNames.includes(node.name); - const isAtTopLevel = (node: TSESTree.Node) => - node.parent.parent.type === 'ExpressionStatement'; + !!node?.parent?.parent && node.parent.parent.type === 'ExpressionStatement'; -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'suggestion', @@ -49,7 +33,6 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ preferExplicitAssertAssertion: '`getBy*` queries must be asserted with `{{assertion}}`', }, - fixable: null, schema: [ { type: 'object', @@ -59,31 +42,23 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ type: 'string', enum: PRESENCE_MATCHERS, }, - customQueryNames: { - type: 'array', - }, }, }, ], }, - defaultOptions: [ - { - customQueryNames: [], - }, - ], - - create: function(context, [options]) { - const { customQueryNames, assertion } = options; + defaultOptions: [{}], + create(context, [options], helpers) { + const { assertion } = options; const getQueryCalls: TSESTree.Identifier[] = []; return { 'CallExpression Identifier'(node: TSESTree.Identifier) { - if (isValidQuery(node, customQueryNames)) { + if (helpers.isGetQueryVariant(node)) { getQueryCalls.push(node); } }, 'Program:exit'() { - getQueryCalls.forEach(queryCall => { + getQueryCalls.forEach((queryCall) => { const node = isMemberExpression(queryCall.parent) ? queryCall.parent : queryCall; @@ -93,7 +68,9 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ node: queryCall, messageId: 'preferExplicitAssert', }); - } else if (assertion) { + } + + if (assertion) { const expectCallNode = findClosestCallNode(node, 'expect'); if (!expectCallNode) return; @@ -105,7 +82,7 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ if ( matcher === 'not' && isMemberExpression(expectStatement.parent) && - isIdentifier(expectStatement.parent.property) + ASTUtils.isIdentifier(expectStatement.parent.property) ) { isNegatedMatcher = true; matcher = expectStatement.parent.property.name; diff --git a/lib/rules/prefer-find-by.ts b/lib/rules/prefer-find-by.ts index 76f2179f..93d71dc0 100644 --- a/lib/rules/prefer-find-by.ts +++ b/lib/rules/prefer-find-by.ts @@ -1,31 +1,31 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; import { - ReportFixFunction, - RuleFix, - Scope, -} from '@typescript-eslint/experimental-utils/dist/ts-eslint'; + TSESTree, + ASTUtils, + TSESLint, +} from '@typescript-eslint/experimental-utils'; import { isArrowFunctionExpression, isCallExpression, - isIdentifier, isMemberExpression, isObjectPattern, isProperty, } from '../node-utils'; -import { getDocsUrl, SYNC_QUERIES_COMBINATIONS } from '../utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'prefer-find-by'; export type MessageIds = 'preferFindBy'; type Options = []; -export const WAIT_METHODS = ['waitFor', 'waitForElement', 'wait']; +export const WAIT_METHODS = ['waitFor', 'waitForElement', 'wait'] as const; -export function getFindByQueryVariant(queryMethod: string) { +export function getFindByQueryVariant( + queryMethod: string +): 'findAllBy' | 'findBy' { return queryMethod.includes('All') ? 'findAllBy' : 'findBy'; } function findRenderDefinitionDeclaration( - scope: Scope.Scope | null, + scope: TSESLint.Scope.Scope | null, query: string ): TSESTree.Identifier | null { if (!scope) { @@ -33,65 +33,78 @@ function findRenderDefinitionDeclaration( } const variable = scope.variables.find( - (v: Scope.Variable) => v.name === query + (v: TSESLint.Scope.Variable) => v.name === query ); if (variable) { - const def = variable.defs.find(({ name }) => name.name === query); - return def.name; + return ( + variable.defs + .map(({ name }) => name) + .filter(ASTUtils.isIdentifier) + .find(({ name }) => name === query) ?? null + ); } return findRenderDefinitionDeclaration(scope.upper, query); } -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'suggestion', docs: { description: - 'Suggest using find* instead of waitFor to wait for elements', + 'Suggest using `find*` query instead of `waitFor` + `get*` to wait for elements', category: 'Best Practices', recommended: 'warn', }, messages: { preferFindBy: - 'Prefer {{queryVariant}}{{queryMethod}} method over using await {{fullQuery}}', + 'Prefer `{{queryVariant}}{{queryMethod}}` query over using `{{waitForMethodName}}` + `{{prevQuery}}`', }, fixable: 'code', schema: [], }, defaultOptions: [], - create(context) { + create(context, _, helpers) { const sourceCode = context.getSourceCode(); /** * Reports the invalid usage of wait* plus getBy/QueryBy methods and automatically fixes the scenario - * @param {TSESTree.CallExpression} node - The CallExpresion node that contains the wait* method - * @param {'findBy' | 'findAllBy'} replacementParams.queryVariant - The variant method used to query: findBy/findByAll. - * @param {string} replacementParams.queryMethod - Suffix string to build the query method (the query-part that comes after the "By"): LabelText, Placeholder, Text, Role, Title, etc. - * @param {ReportFixFunction} replacementParams.fix - Function that applies the fix to correct the code + * @param node - The CallExpresion node that contains the wait* method + * @param replacementParams - Object with info for error message and autofix: + * @param replacementParams.queryVariant - The variant method used to query: findBy/findAllBy. + * @param replacementParams.prevQuery - The query originally used inside `waitFor` + * @param replacementParams.queryMethod - Suffix string to build the query method (the query-part that comes after the "By"): LabelText, Placeholder, Text, Role, Title, etc. + * @param replacementParams.waitForMethodName - wait for method used: waitFor/wait/waitForElement + * @param replacementParams.fix - Function that applies the fix to correct the code */ function reportInvalidUsage( node: TSESTree.CallExpression, - { - queryVariant, - queryMethod, - fix, - }: { + replacementParams: { queryVariant: 'findBy' | 'findAllBy'; queryMethod: string; - fix: ReportFixFunction; + prevQuery: string; + waitForMethodName: string; + fix: TSESLint.ReportFixFunction; } ) { + const { + queryMethod, + queryVariant, + prevQuery, + waitForMethodName, + fix, + } = replacementParams; context.report({ node, messageId: 'preferFindBy', data: { queryVariant, queryMethod, - fullQuery: sourceCode.getText(node), + prevQuery, + waitForMethodName, }, fix, }); @@ -100,8 +113,8 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ return { 'AwaitExpression > CallExpression'(node: TSESTree.CallExpression) { if ( - !isIdentifier(node.callee) || - !WAIT_METHODS.includes(node.callee.name) + !ASTUtils.isIdentifier(node.callee) || + !helpers.isAsyncUtil(node.callee, WAIT_METHODS) ) { return; } @@ -114,12 +127,15 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ if (!isCallExpression(argument.body)) { return; } + + const waitForMethodName = node.callee.name; + // ensure here it's one of the sync methods that we are calling if ( isMemberExpression(argument.body.callee) && - isIdentifier(argument.body.callee.property) && - isIdentifier(argument.body.callee.object) && - SYNC_QUERIES_COMBINATIONS.includes(argument.body.callee.property.name) + ASTUtils.isIdentifier(argument.body.callee.property) && + ASTUtils.isIdentifier(argument.body.callee.object) && + helpers.isSyncQuery(argument.body.callee.property) ) { // shape of () => screen.getByText const fullQueryMethod = argument.body.callee.property.name; @@ -131,9 +147,16 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ reportInvalidUsage(node, { queryMethod, queryVariant, + prevQuery: fullQueryMethod, + waitForMethodName, fix(fixer) { + const property = ((argument.body as TSESTree.CallExpression) + .callee as TSESTree.MemberExpression).property; + if (helpers.isCustomQuery(property as TSESTree.Identifier)) { + return null; + } const newCode = `${caller}.${queryVariant}${queryMethod}(${callArguments - .map(node => sourceCode.getText(node)) + .map((node) => sourceCode.getText(node)) .join(', ')})`; return fixer.replaceText(node, newCode); }, @@ -141,65 +164,79 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ return; } if ( - isIdentifier(argument.body.callee) && - SYNC_QUERIES_COMBINATIONS.includes(argument.body.callee.name) + !ASTUtils.isIdentifier(argument.body.callee) || + !helpers.isSyncQuery(argument.body.callee) ) { - // shape of () => getByText - const fullQueryMethod = argument.body.callee.name; - const queryMethod = fullQueryMethod.split('By')[1]; - const queryVariant = getFindByQueryVariant(fullQueryMethod); - const callArguments = argument.body.arguments; + return; + } + // shape of () => getByText + const fullQueryMethod = argument.body.callee.name; + const queryMethod = fullQueryMethod.split('By')[1]; + const queryVariant = getFindByQueryVariant(fullQueryMethod); + const callArguments = argument.body.arguments; - reportInvalidUsage(node, { - queryMethod, - queryVariant, - fix(fixer) { - const findByMethod = `${queryVariant}${queryMethod}`; - const allFixes: RuleFix[] = []; - // this updates waitFor with findBy* - const newCode = `${findByMethod}(${callArguments - .map(node => sourceCode.getText(node)) - .join(', ')})`; - allFixes.push(fixer.replaceText(node, newCode)); + reportInvalidUsage(node, { + queryMethod, + queryVariant, + prevQuery: fullQueryMethod, + waitForMethodName, + fix(fixer) { + // we know from above callee is an Identifier + if ( + helpers.isCustomQuery( + (argument.body as TSESTree.CallExpression) + .callee as TSESTree.Identifier + ) + ) { + return null; + } + const findByMethod = `${queryVariant}${queryMethod}`; + const allFixes: TSESLint.RuleFix[] = []; + // this updates waitFor with findBy* + const newCode = `${findByMethod}(${callArguments + .map((node) => sourceCode.getText(node)) + .join(', ')})`; + allFixes.push(fixer.replaceText(node, newCode)); - // this adds the findBy* declaration - adding it to the list of destructured variables { findBy* } = render() - const definition = findRenderDefinitionDeclaration( - context.getScope(), - fullQueryMethod - ); - // I think it should always find it, otherwise code should not be valid (it'd be using undeclared variables) - if (!definition) { + // this adds the findBy* declaration - adding it to the list of destructured variables { findBy* } = render() + const definition = findRenderDefinitionDeclaration( + context.getScope(), + fullQueryMethod + ); + // I think it should always find it, otherwise code should not be valid (it'd be using undeclared variables) + if (!definition) { + return allFixes; + } + // check the declaration is part of a destructuring + if ( + definition.parent && + isObjectPattern(definition.parent.parent) + ) { + const allVariableDeclarations = definition.parent.parent; + // verify if the findBy* method was already declared + if ( + allVariableDeclarations.properties.some( + (p) => + isProperty(p) && + ASTUtils.isIdentifier(p.key) && + p.key.name === findByMethod + ) + ) { return allFixes; } - // check the declaration is part of a destructuring - if (isObjectPattern(definition.parent.parent)) { - const allVariableDeclarations = definition.parent.parent; - // verify if the findBy* method was already declared - if ( - allVariableDeclarations.properties.some( - p => - isProperty(p) && - isIdentifier(p.key) && - p.key.name === findByMethod - ) - ) { - return allFixes; - } - // the last character of a destructuring is always a "}", so we should replace it with the findBy* declaration - const textDestructuring = sourceCode.getText( - allVariableDeclarations - ); - const text = - textDestructuring.substring(0, textDestructuring.length - 2) + - `, ${findByMethod} }`; - allFixes.push(fixer.replaceText(allVariableDeclarations, text)); - } + // the last character of a destructuring is always a "}", so we should replace it with the findBy* declaration + const textDestructuring = sourceCode.getText( + allVariableDeclarations + ); + const text = + textDestructuring.substring(0, textDestructuring.length - 2) + + `, ${findByMethod} }`; + allFixes.push(fixer.replaceText(allVariableDeclarations, text)); + } - return allFixes; - }, - }); - return; - } + return allFixes; + }, + }); }, }; }, diff --git a/lib/rules/prefer-presence-queries.ts b/lib/rules/prefer-presence-queries.ts index 06c87515..97d4d329 100644 --- a/lib/rules/prefer-presence-queries.ts +++ b/lib/rules/prefer-presence-queries.ts @@ -1,24 +1,12 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl, ALL_QUERIES_METHODS, PRESENCE_MATCHERS, ABSENCE_MATCHERS } from '../utils'; -import { - findClosestCallNode, - isMemberExpression, - isIdentifier, -} from '../node-utils'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { findClosestCallNode, isMemberExpression } from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'prefer-presence-queries'; -export type MessageIds = 'presenceQuery' | 'absenceQuery' | 'expectQueryBy'; +export type MessageIds = 'wrongPresenceQuery' | 'wrongAbsenceQuery'; type Options = []; -const QUERIES_REGEXP = new RegExp( - `^(get|query)(All)?(${ALL_QUERIES_METHODS.join('|')})$` -); - -function isThrowingQuery(node: TSESTree.Identifier) { - return node.name.startsWith('get'); -} - -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { docs: { @@ -28,62 +16,46 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ recommended: 'error', }, messages: { - presenceQuery: + wrongPresenceQuery: 'Use `getBy*` queries rather than `queryBy*` for checking element is present', - absenceQuery: + wrongAbsenceQuery: 'Use `queryBy*` queries rather than `getBy*` for checking element is NOT present', - expectQueryBy: - 'Use `getBy*` only when checking elements are present, otherwise use `queryBy*`', }, schema: [], type: 'suggestion', - fixable: null, }, defaultOptions: [], - create(context) { + create(context, _, helpers) { return { - [`CallExpression Identifier[name=${QUERIES_REGEXP}]`]( - node: TSESTree.Identifier - ) { + 'CallExpression Identifier'(node: TSESTree.Identifier) { const expectCallNode = findClosestCallNode(node, 'expect'); - if (expectCallNode && isMemberExpression(expectCallNode.parent)) { - const expectStatement = expectCallNode.parent; - const property = expectStatement.property as TSESTree.Identifier; - let matcher = property.name; - let isNegatedMatcher = false; + if (!expectCallNode || !isMemberExpression(expectCallNode.parent)) { + return; + } - if ( - matcher === 'not' && - isMemberExpression(expectStatement.parent) && - isIdentifier(expectStatement.parent.property) - ) { - isNegatedMatcher = true; - matcher = expectStatement.parent.property.name; - } + // Sync queries (getBy and queryBy) are corresponding ones used + // to check presence or absence. If none found, stop the rule. + if (!helpers.isSyncQuery(node)) { + return; + } - const validMatchers = isThrowingQuery(node) - ? PRESENCE_MATCHERS - : ABSENCE_MATCHERS; + const isPresenceQuery = helpers.isGetQueryVariant(node); + const expectStatement = expectCallNode.parent; + const isPresenceAssert = helpers.isPresenceAssert(expectStatement); + const isAbsenceAssert = helpers.isAbsenceAssert(expectStatement); - const invalidMatchers = isThrowingQuery(node) - ? ABSENCE_MATCHERS - : PRESENCE_MATCHERS; + if (!isPresenceAssert && !isAbsenceAssert) { + return; + } - const messageId = isThrowingQuery(node) - ? 'absenceQuery' - : 'presenceQuery'; + if (isPresenceAssert && !isPresenceQuery) { + return context.report({ node, messageId: 'wrongPresenceQuery' }); + } - if ( - (!isNegatedMatcher && invalidMatchers.includes(matcher)) || - (isNegatedMatcher && validMatchers.includes(matcher)) - ) { - return context.report({ - node, - messageId, - }); - } + if (isAbsenceAssert && isPresenceQuery) { + return context.report({ node, messageId: 'wrongAbsenceQuery' }); } }, }; diff --git a/lib/rules/prefer-screen-queries.ts b/lib/rules/prefer-screen-queries.ts index 5373a7b7..650e5986 100644 --- a/lib/rules/prefer-screen-queries.ts +++ b/lib/rules/prefer-screen-queries.ts @@ -1,13 +1,12 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl, ALL_QUERIES_COMBINATIONS } from '../utils'; +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; import { + isCallExpression, isMemberExpression, + isObjectExpression, isObjectPattern, - isCallExpression, isProperty, - isIdentifier, - isObjectExpression, } from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'prefer-screen-queries'; export type MessageIds = 'preferScreenQueries'; @@ -17,40 +16,38 @@ const ALLOWED_RENDER_PROPERTIES_FOR_DESTRUCTURING = [ 'container', 'baseElement', ]; -const ALL_QUERIES_COMBINATIONS_REGEXP = ALL_QUERIES_COMBINATIONS.join('|'); function usesContainerOrBaseElement(node: TSESTree.CallExpression) { const secondArgument = node.arguments[1]; return ( isObjectExpression(secondArgument) && secondArgument.properties.some( - property => + (property) => isProperty(property) && - isIdentifier(property.key) && + ASTUtils.isIdentifier(property.key) && ALLOWED_RENDER_PROPERTIES_FOR_DESTRUCTURING.includes(property.key.name) ) ); } -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'suggestion', docs: { - description: 'Suggest using screen while using queries', + description: 'Suggest using screen while querying', category: 'Best Practices', - recommended: false, + recommended: 'error', }, messages: { preferScreenQueries: - 'Use screen to query DOM elements, `screen.{{ name }}`', + 'Avoid destructuring queries from `render` result, use `screen.{{ name }}` instead', }, - fixable: null, schema: [], }, defaultOptions: [], - create(context) { + create(context, _, helpers) { function reportInvalidUsage(node: TSESTree.Identifier) { context.report({ node, @@ -61,73 +58,89 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ }); } - const queriesRegex = new RegExp(ALL_QUERIES_COMBINATIONS_REGEXP); - const queriesDestructuredInWithinDeclaration: string[] = []; + function saveSafeDestructuredQueries(node: TSESTree.VariableDeclarator) { + if (isObjectPattern(node.id)) { + for (const property of node.id.properties) { + if ( + isProperty(property) && + ASTUtils.isIdentifier(property.key) && + helpers.isQuery(property.key) + ) { + safeDestructuredQueries.push(property.key.name); + } + } + } + } + + // keep here those queries which are safe and shouldn't be reported + // (from within, from render + container/base element, not related to TL, etc) + const safeDestructuredQueries: string[] = []; // use an array as within might be used more than once in a test const withinDeclaredVariables: string[] = []; return { VariableDeclarator(node) { - if (!isCallExpression(node.init) || !isIdentifier(node.init.callee)) { + if ( + !isCallExpression(node.init) || + !ASTUtils.isIdentifier(node.init.callee) + ) { return; } + + const isComingFromValidRender = helpers.isRenderUtil(node.init.callee); + + if (!isComingFromValidRender) { + // save the destructured query methods as safe since they are coming + // from render not related to TL + saveSafeDestructuredQueries(node); + } + const isWithinFunction = node.init.callee.name === 'within'; - // TODO add the custom render option #198 const usesRenderOptions = - node.init.callee.name === 'render' && - usesContainerOrBaseElement(node.init); + isComingFromValidRender && usesContainerOrBaseElement(node.init); if (!isWithinFunction && !usesRenderOptions) { return; } if (isObjectPattern(node.id)) { - // save the destructured query methods - const identifiers = node.id.properties - .filter( - property => - isProperty(property) && - isIdentifier(property.key) && - queriesRegex.test(property.key.name) - ) - .map( - (property: TSESTree.Property) => - (property.key as TSESTree.Identifier).name - ); - - queriesDestructuredInWithinDeclaration.push(...identifiers); + // save the destructured query methods as safe since they are coming + // from within or render + base/container options + saveSafeDestructuredQueries(node); return; } - if (isIdentifier(node.id)) { + if (ASTUtils.isIdentifier(node.id)) { withinDeclaredVariables.push(node.id.name); } }, - [`CallExpression > Identifier[name=/^${ALL_QUERIES_COMBINATIONS_REGEXP}$/]`]( - node: TSESTree.Identifier - ) { + 'CallExpression > Identifier'(node: TSESTree.Identifier) { + if (!helpers.isQuery(node)) { + return; + } + if ( - !queriesDestructuredInWithinDeclaration.some( - queryName => queryName === node.name - ) + !safeDestructuredQueries.some((queryName) => queryName === node.name) ) { reportInvalidUsage(node); } }, - [`MemberExpression > Identifier[name=/^${ALL_QUERIES_COMBINATIONS_REGEXP}$/]`]( - node: TSESTree.Identifier - ) { + 'MemberExpression > Identifier'(node: TSESTree.Identifier) { function isIdentifierAllowed(name: string) { return ['screen', ...withinDeclaredVariables].includes(name); } + if (!helpers.isQuery(node)) { + return; + } + if ( - isIdentifier(node) && + ASTUtils.isIdentifier(node) && isMemberExpression(node.parent) && isCallExpression(node.parent.object) && - isIdentifier(node.parent.object.callee) && + ASTUtils.isIdentifier(node.parent.object.callee) && node.parent.object.callee.name !== 'within' && - node.parent.object.callee.name === 'render' && + helpers.isRenderUtil(node.parent.object.callee) && !usesContainerOrBaseElement(node.parent.object) ) { reportInvalidUsage(node); @@ -136,7 +149,7 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ if ( isMemberExpression(node.parent) && - isIdentifier(node.parent.object) && + ASTUtils.isIdentifier(node.parent.object) && !isIdentifierAllowed(node.parent.object.name) ) { reportInvalidUsage(node); diff --git a/lib/rules/prefer-user-event.ts b/lib/rules/prefer-user-event.ts new file mode 100644 index 00000000..73bdb6c8 --- /dev/null +++ b/lib/rules/prefer-user-event.ts @@ -0,0 +1,121 @@ +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; +import { findClosestCallExpressionNode } from '../node-utils'; + +export const RULE_NAME = 'prefer-user-event'; + +export type MessageIds = 'preferUserEvent'; +export type Options = [{ allowedMethods: string[] }]; + +export const UserEventMethods = [ + 'click', + 'dblClick', + 'type', + 'upload', + 'clear', + 'selectOptions', + 'deselectOptions', + 'tab', + 'hover', + 'unhover', + 'paste', +] as const; +type UserEventMethodsType = typeof UserEventMethods[number]; + +// maps fireEvent methods to userEvent. Those not found here, do not have an equivalent (yet) +export const MAPPING_TO_USER_EVENT: Record = { + click: ['click', 'type', 'selectOptions', 'deselectOptions'], + change: ['upload', 'type', 'clear', 'selectOptions', 'deselectOptions'], + dblClick: ['dblClick'], + input: ['type', 'upload', 'selectOptions', 'deselectOptions', 'paste'], + keyDown: ['type', 'tab'], + keyPress: ['type'], + keyUp: ['type', 'tab'], + mouseDown: ['click', 'dblClick', 'selectOptions', 'deselectOptions'], + mouseEnter: ['hover', 'selectOptions', 'deselectOptions'], + mouseLeave: ['unhover'], + mouseMove: ['hover', 'unhover', 'selectOptions', 'deselectOptions'], + mouseOut: ['unhover'], + mouseOver: ['hover', 'selectOptions', 'deselectOptions'], + mouseUp: ['click', 'dblClick', 'selectOptions', 'deselectOptions'], + paste: ['paste'], + pointerDown: ['click', 'dblClick', 'selectOptions', 'deselectOptions'], + pointerEnter: ['hover', 'selectOptions', 'deselectOptions'], + pointerLeave: ['unhover'], + pointerMove: ['hover', 'unhover', 'selectOptions', 'deselectOptions'], + pointerOut: ['unhover'], + pointerOver: ['hover', 'selectOptions', 'deselectOptions'], + pointerUp: ['click', 'dblClick', 'selectOptions', 'deselectOptions'], +}; + +function buildErrorMessage(fireEventMethod: string) { + const userEventMethods = MAPPING_TO_USER_EVENT[fireEventMethod].map( + (methodName) => `userEvent.${methodName}` + ); + + // TODO: when min node version is 13, we can reimplement this using `Intl.ListFormat` + return userEventMethods.join(', ').replace(/, ([a-zA-Z.]+)$/, ', or $1'); +} + +const fireEventMappedMethods = Object.keys(MAPPING_TO_USER_EVENT); + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'Suggest using userEvent over fireEvent', + category: 'Best Practices', + recommended: 'warn', + }, + messages: { + preferUserEvent: + 'Prefer using {{userEventMethods}} over fireEvent.{{fireEventMethod}}', + }, + schema: [ + { + type: 'object', + properties: { + allowedMethods: { type: 'array' }, + }, + }, + ], + }, + defaultOptions: [{ allowedMethods: [] }], + + create(context, [options], helpers) { + const { allowedMethods } = options; + + return { + 'CallExpression Identifier'(node: TSESTree.Identifier) { + if (!helpers.isFireEventMethod(node)) { + return; + } + + const closestCallExpression = findClosestCallExpressionNode(node, true); + + if (!closestCallExpression) { + return; + } + + const fireEventMethodName: string = node.name; + + if ( + !fireEventMappedMethods.includes(fireEventMethodName) || + allowedMethods.includes(fireEventMethodName) + ) { + return; + } + + context.report({ + node: closestCallExpression.callee, + messageId: 'preferUserEvent', + data: { + userEventMethods: buildErrorMessage(fireEventMethodName), + fireEventMethod: fireEventMethodName, + }, + }); + }, + }; + }, +}); diff --git a/lib/rules/prefer-wait-for.ts b/lib/rules/prefer-wait-for.ts index cf5b6967..8fe9c37c 100644 --- a/lib/rules/prefer-wait-for.ts +++ b/lib/rules/prefer-wait-for.ts @@ -1,19 +1,25 @@ -import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl } from '../utils'; +import { TSESTree, ASTUtils } from '@typescript-eslint/experimental-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; import { isImportSpecifier, isMemberExpression, - isIdentifier, findClosestCallExpressionNode, + isCallExpression, + isImportNamespaceSpecifier, + isObjectPattern, + isProperty, } from '../node-utils'; export const RULE_NAME = 'prefer-wait-for'; -export type MessageIds = 'preferWaitForMethod' | 'preferWaitForImport'; +export type MessageIds = + | 'preferWaitForMethod' + | 'preferWaitForImport' + | 'preferWaitForRequire'; type Options = []; const DEPRECATED_METHODS = ['wait', 'waitForElement', 'waitForDomChange']; -export default ESLintUtils.RuleCreator(getDocsUrl)({ +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'suggestion', @@ -26,6 +32,8 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ preferWaitForMethod: '`{{ methodName }}` is deprecated in favour of `waitFor`', preferWaitForImport: 'import `waitFor` instead of deprecated async utils', + preferWaitForRequire: + 'require `waitFor` instead of deprecated async utils', }, fixable: 'code', @@ -33,7 +41,34 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ }, defaultOptions: [], - create(context) { + create(context, _, helpers) { + let addWaitFor = false; + + const reportRequire = (node: TSESTree.ObjectPattern) => { + context.report({ + node: node, + messageId: 'preferWaitForRequire', + fix(fixer) { + const excludedImports = [...DEPRECATED_METHODS, 'waitFor']; + + const newAllRequired = node.properties + .filter( + (s) => + isProperty(s) && + ASTUtils.isIdentifier(s.key) && + !excludedImports.includes(s.key.name) + ) + .map( + (s) => ((s as TSESTree.Property).key as TSESTree.Identifier).name + ); + + newAllRequired.push('waitFor'); + + return fixer.replaceText(node, `{ ${newAllRequired.join(',')} }`); + }, + }); + }; + const reportImport = (node: TSESTree.ImportDeclaration) => { context.report({ node: node, @@ -43,14 +78,13 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ // get all import names excluding all testing library `wait*` utils... const newImports = node.specifiers - .filter( - specifier => + .map( + (specifier) => isImportSpecifier(specifier) && - !excludedImports.includes(specifier.imported.name) + !excludedImports.includes(specifier.imported.name) && + specifier.imported.name ) - .map( - (specifier: TSESTree.ImportSpecifier) => specifier.imported.name - ); + .filter(Boolean) as string[]; // ... and append `waitFor` newImports.push('waitFor'); @@ -65,15 +99,18 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ }); }; - const reportWait = (node: TSESTree.Identifier) => { + const reportWait = (node: TSESTree.Identifier | TSESTree.JSXIdentifier) => { context.report({ - node: node, + node, messageId: 'preferWaitForMethod', data: { methodName: node.name, }, fix(fixer) { const callExpressionNode = findClosestCallExpressionNode(node); + if (!callExpressionNode) { + return null; + } const [arg] = callExpressionNode.arguments; const fixers = []; @@ -97,7 +134,7 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ // member expression to get `foo.waitFor(() => {})` if ( isMemberExpression(node.parent) && - isIdentifier(node.parent.object) + ASTUtils.isIdentifier(node.parent.object) ) { methodReplacement = `${node.parent.object.name}.${methodReplacement}`; } @@ -112,46 +149,57 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ }; return { - 'ImportDeclaration[source.value=/testing-library/]'( - node: TSESTree.ImportDeclaration - ) { - const deprecatedImportSpecifiers = node.specifiers.filter( - specifier => - isImportSpecifier(specifier) && - specifier.imported && - DEPRECATED_METHODS.includes(specifier.imported.name) - ); - - deprecatedImportSpecifiers.forEach((importSpecifier, i) => { - if (i === 0) { - reportImport(node); - } - - context - .getDeclaredVariables(importSpecifier) - .forEach(variable => - variable.references.forEach(reference => - reportWait(reference.identifier) - ) - ); - }); + 'CallExpression > MemberExpression'(node: TSESTree.MemberExpression) { + const isDeprecatedMethod = + ASTUtils.isIdentifier(node.property) && + DEPRECATED_METHODS.includes(node.property.name); + if (!isDeprecatedMethod) { + // the method does not match a deprecated method + return; + } + if (!helpers.isNodeComingFromTestingLibrary(node)) { + // the method does not match from the imported elements from TL (even from custom) + return; + } + addWaitFor = true; + reportWait(node.property as TSESTree.Identifier); // compiler is not picking up correctly, it should have inferred it is an identifier }, - 'ImportDeclaration[source.value=/testing-library/] > ImportNamespaceSpecifier'( - node: TSESTree.ImportNamespaceSpecifier - ) { - context.getDeclaredVariables(node).forEach(variable => - variable.references.forEach(reference => { - if ( - isMemberExpression(reference.identifier.parent) && - isIdentifier(reference.identifier.parent.property) && - DEPRECATED_METHODS.includes( - reference.identifier.parent.property.name - ) - ) { - reportWait(reference.identifier.parent.property); - } - }) - ); + 'CallExpression > Identifier'(node: TSESTree.Identifier) { + if (!DEPRECATED_METHODS.includes(node.name)) { + return; + } + + if (!helpers.isNodeComingFromTestingLibrary(node)) { + return; + } + addWaitFor = true; + reportWait(node); + }, + 'Program:exit'() { + if (!addWaitFor) { + return; + } + // now that all usages of deprecated methods were replaced, remove the extra imports + const testingLibraryNode = + helpers.getCustomModuleImportNode() ?? + helpers.getTestingLibraryImportNode(); + if (isCallExpression(testingLibraryNode)) { + const parent = testingLibraryNode.parent as TSESTree.VariableDeclarator; + if (!isObjectPattern(parent.id)) { + // if there is no destructuring, there is nothing to replace + return; + } + reportRequire(parent.id); + } else if (testingLibraryNode) { + if ( + testingLibraryNode.specifiers.length === 1 && + isImportNamespaceSpecifier(testingLibraryNode.specifiers[0]) + ) { + // if we import everything, there is nothing to replace + return; + } + reportImport(testingLibraryNode); + } }, }; }, diff --git a/lib/rules/render-result-naming-convention.ts b/lib/rules/render-result-naming-convention.ts new file mode 100644 index 00000000..90dbbc20 --- /dev/null +++ b/lib/rules/render-result-naming-convention.ts @@ -0,0 +1,106 @@ +import { createTestingLibraryRule } from '../create-testing-library-rule'; +import { + getDeepestIdentifierNode, + getFunctionName, + getInnermostReturningFunction, + isObjectPattern, +} from '../node-utils'; +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; + +export const RULE_NAME = 'render-result-naming-convention'; +export type MessageIds = 'renderResultNamingConvention'; + +type Options = []; + +const ALLOWED_VAR_NAMES = ['view', 'utils']; +const ALLOWED_VAR_NAMES_TEXT = ALLOWED_VAR_NAMES.map( + (name) => `\`${name}\`` +).join(', '); + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'Enforce a valid naming for return value from `render`', + category: 'Best Practices', + recommended: false, + }, + messages: { + 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}`, + }, + schema: [], + }, + defaultOptions: [], + + create(context, _, helpers) { + const renderWrapperNames: string[] = []; + + function detectRenderWrapper(node: TSESTree.Identifier): void { + const innerFunction = getInnermostReturningFunction(context, node); + + if (innerFunction) { + renderWrapperNames.push(getFunctionName(innerFunction)); + } + } + + return { + CallExpression(node) { + const callExpressionIdentifier = getDeepestIdentifierNode(node); + + if (!callExpressionIdentifier) { + return; + } + + if (helpers.isRenderUtil(callExpressionIdentifier)) { + detectRenderWrapper(callExpressionIdentifier); + } + }, + VariableDeclarator(node) { + if (!node.init) { + return; + } + const initIdentifierNode = getDeepestIdentifierNode(node.init); + + if (!initIdentifierNode) { + return; + } + + if ( + !helpers.isRenderVariableDeclarator(node) && + !renderWrapperNames.includes(initIdentifierNode.name) + ) { + return; + } + + // check if destructuring return value from render + if (isObjectPattern(node.id)) { + return; + } + + const renderResultName = ASTUtils.isIdentifier(node.id) && node.id.name; + + if (!renderResultName) { + return; + } + + const isAllowedRenderResultName = ALLOWED_VAR_NAMES.includes( + renderResultName + ); + + // check if return value var name is allowed + if (isAllowedRenderResultName) { + return; + } + + context.report({ + node, + messageId: 'renderResultNamingConvention', + data: { + renderResultName, + }, + }); + }, + }; + }, +}); diff --git a/lib/utils.ts b/lib/utils.ts index d3781e64..71850468 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,8 +1,8 @@ -const combineQueries = (variants: string[], methods: string[]) => { +const combineQueries = (variants: string[], methods: string[]): string[] => { const combinedQueries: string[] = []; - variants.forEach(variant => { + variants.forEach((variant) => { const variantPrefix = variant.replace('By', ''); - methods.forEach(method => { + methods.forEach((method) => { combinedQueries.push(`${variantPrefix}${method}`); }); }); @@ -61,19 +61,52 @@ const ASYNC_UTILS = [ 'wait', 'waitForElement', 'waitForDomChange', +] as const; + +const EVENTS_SIMULATORS = ['fireEvent', 'userEvent'] as const; + +const TESTING_FRAMEWORK_SETUP_HOOKS = ['beforeEach', 'beforeAll']; + +const PROPERTIES_RETURNING_NODES = [ + 'activeElement', + 'children', + 'firstChild', + 'firstElementChild', + 'fullscreenElement', + 'lastChild', + 'lastElementChild', + 'nextElementSibling', + 'nextSibling', + 'parentElement', + 'parentNode', + 'pointerLockElement', + 'previousElementSibling', + 'previousSibling', + 'rootNode', + 'scripts', ]; -const SYNC_EVENTS = [ - 'fireEvent', - 'userEvent', +const METHODS_RETURNING_NODES = [ + 'closest', + 'getElementById', + 'getElementsByClassName', + 'getElementsByName', + 'getElementsByTagName', + 'getElementsByTagNameNS', + 'querySelector', + 'querySelectorAll', ]; -const TESTING_FRAMEWORK_SETUP_HOOKS = ['beforeEach', 'beforeAll']; +const ALL_RETURNING_NODES = [ + ...PROPERTIES_RETURNING_NODES, + ...METHODS_RETURNING_NODES, +]; const PRESENCE_MATCHERS = ['toBeInTheDocument', 'toBeTruthy', 'toBeDefined']; const ABSENCE_MATCHERS = ['toBeNull', 'toBeFalsy']; export { + combineQueries, getDocsUrl, SYNC_QUERIES_VARIANTS, ASYNC_QUERIES_VARIANTS, @@ -83,9 +116,12 @@ export { ASYNC_QUERIES_COMBINATIONS, ALL_QUERIES_COMBINATIONS, ASYNC_UTILS, - SYNC_EVENTS, + EVENTS_SIMULATORS, TESTING_FRAMEWORK_SETUP_HOOKS, LIBRARY_MODULES, + PROPERTIES_RETURNING_NODES, + METHODS_RETURNING_NODES, + ALL_RETURNING_NODES, PRESENCE_MATCHERS, - ABSENCE_MATCHERS + ABSENCE_MATCHERS, }; diff --git a/package.json b/package.json index ab457434..57214962 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "keywords": [ "eslint", "eslintplugin", - "eslint-plugin" + "eslint-plugin", + "lint", + "testing-library", + "testing" ], "author": { "name": "Mario Beltrán Alarcón", @@ -20,66 +23,51 @@ "bugs": { "url": "https://github.com/testing-library/eslint-plugin-testing-library/issues" }, - "release": { - "pkgRoot": "dist", - "branches": [ - "+([0-9])?(.{+([0-9]),x}).x", - "main", - "next", - "next-major", - { - "name": "beta", - "prerelease": true - }, - { - "name": "alpha", - "prerelease": true - } - ] - }, "main": "index.js", "scripts": { "build": "tsc", "postbuild": "cpy README.md ./dist && cpy package.json ./dist && cpy LICENSE ./dist", - "lint": "eslint . --ext .js,.ts", + "lint": "eslint . --max-warnings 0 --ext .js,.ts", "lint:fix": "npm run lint -- --fix", - "format": "prettier --write README.md {lib,docs,tests}/**/*.{js,md}", + "format": "prettier --write README.md \"{lib,docs,tests}/**/*.{js,ts,md}\"", + "format:check": "prettier --check README.md \"{lib,docs,tests}/**/*.{js,json,yml,ts,md}\"", "test": "jest", "test:ci": "jest --ci --coverage", "test:update": "npm run test -- --u", "test:watch": "npm run test -- --watch", + "type-check": "tsc --noEmit", "semantic-release": "semantic-release" }, "dependencies": { - "@typescript-eslint/experimental-utils": "^3.10.1" + "@typescript-eslint/experimental-utils": "^4.21.0" }, "devDependencies": { - "@commitlint/cli": "^9.1.2", - "@commitlint/config-conventional": "^9.1.2", - "@types/jest": "^25.2.3", - "@typescript-eslint/eslint-plugin": "^3.10.1", - "@typescript-eslint/parser": "^3.10.1", + "@commitlint/cli": "^12.1.1", + "@commitlint/config-conventional": "^12.1.1", + "@types/jest": "^26.0.22", + "@typescript-eslint/eslint-plugin": "^4.21.0", + "@typescript-eslint/parser": "^4.21.0", "cpy-cli": "^3.1.1", - "eslint": "^7.9.0", - "eslint-config-prettier": "^6.11.0", - "eslint-config-standard": "^14.1.1", - "eslint-plugin-import": "^2.22.0", - "eslint-plugin-jest": "^24.0.2", - "eslint-plugin-jest-formatting": "^2.0.0", + "eslint": "^7.24.0", + "eslint-config-prettier": "^8.1.0", + "eslint-config-standard": "^16.0.2", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jest": "^24.3.4", + "eslint-plugin-jest-formatting": "^2.0.1", "eslint-plugin-node": "^11.1.0", - "eslint-plugin-prettier": "^3.1.4", - "eslint-plugin-promise": "^4.2.1", - "eslint-plugin-standard": "^4.0.1", - "husky": "^4.3.0", - "jest": "^25.5.4", - "lint-staged": "^9.5.0", - "prettier": "1.19.1", - "semantic-release": "^16.0.4", - "ts-jest": "^25.5.1", - "typescript": "^4.0.3" + "eslint-plugin-prettier": "^3.3.1", + "eslint-plugin-promise": "^5.1.0", + "husky": "^4.3.8", + "jest": "^26.6.3", + "jest-environment-jsdom": "^25.5.0", + "lint-staged": "^10.5.4", + "prettier": "2.2.1", + "semantic-release": "^17.4.2", + "ts-jest": "^26.5.4", + "typescript": "^4.2.4" }, "peerDependencies": { - "eslint": "^5 || ^6 || ^7" + "eslint": "^7.5.0" }, "engines": { "node": "^10.12.0 || >=12.0.0", diff --git a/tests/__snapshots__/index.test.ts.snap b/tests/__snapshots__/index.test.ts.snap index fedc3c05..1b3fa8c7 100644 --- a/tests/__snapshots__/index.test.ts.snap +++ b/tests/__snapshots__/index.test.ts.snap @@ -9,17 +9,23 @@ Object { "testing-library/await-async-query": "error", "testing-library/await-async-utils": "error", "testing-library/no-await-sync-query": "error", - "testing-library/no-debug": "warn", + "testing-library/no-container": "error", + "testing-library/no-debug": "error", "testing-library/no-dom-import": Array [ "error", "angular", ], + "testing-library/no-node-access": "error", + "testing-library/no-promise-in-fire-event": "error", + "testing-library/no-wait-for-empty-callback": "error", "testing-library/prefer-find-by": "error", + "testing-library/prefer-screen-queries": "error", + "testing-library/render-result-naming-convention": "error", }, } `; -exports[`should export proper "react" config 1`] = ` +exports[`should export proper "dom" config 1`] = ` Object { "plugins": Array [ "testing-library", @@ -28,17 +34,15 @@ Object { "testing-library/await-async-query": "error", "testing-library/await-async-utils": "error", "testing-library/no-await-sync-query": "error", - "testing-library/no-debug": "warn", - "testing-library/no-dom-import": Array [ - "error", - "react", - ], + "testing-library/no-promise-in-fire-event": "error", + "testing-library/no-wait-for-empty-callback": "error", "testing-library/prefer-find-by": "error", + "testing-library/prefer-screen-queries": "error", }, } `; -exports[`should export proper "recommended" config 1`] = ` +exports[`should export proper "react" config 1`] = ` Object { "plugins": Array [ "testing-library", @@ -47,7 +51,18 @@ Object { "testing-library/await-async-query": "error", "testing-library/await-async-utils": "error", "testing-library/no-await-sync-query": "error", + "testing-library/no-container": "error", + "testing-library/no-debug": "error", + "testing-library/no-dom-import": Array [ + "error", + "react", + ], + "testing-library/no-node-access": "error", + "testing-library/no-promise-in-fire-event": "error", + "testing-library/no-wait-for-empty-callback": "error", "testing-library/prefer-find-by": "error", + "testing-library/prefer-screen-queries": "error", + "testing-library/render-result-naming-convention": "error", }, } `; @@ -62,12 +77,18 @@ Object { "testing-library/await-async-utils": "error", "testing-library/await-fire-event": "error", "testing-library/no-await-sync-query": "error", - "testing-library/no-debug": "warn", + "testing-library/no-container": "error", + "testing-library/no-debug": "error", "testing-library/no-dom-import": Array [ "error", "vue", ], + "testing-library/no-node-access": "error", + "testing-library/no-promise-in-fire-event": "error", + "testing-library/no-wait-for-empty-callback": "error", "testing-library/prefer-find-by": "error", + "testing-library/prefer-screen-queries": "error", + "testing-library/render-result-naming-convention": "error", }, } `; diff --git a/tests/create-testing-library-rule.test.ts b/tests/create-testing-library-rule.test.ts new file mode 100644 index 00000000..93a67506 --- /dev/null +++ b/tests/create-testing-library-rule.test.ts @@ -0,0 +1,844 @@ +import { createRuleTester } from './lib/test-utils'; +import rule, { RULE_NAME } from './fake-rule'; + +const ruleTester = createRuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + // Test Cases for Imports + { + code: ` + // case: nothing related to Testing Library at all + import { shallow } from 'enzyme'; + + const wrapper = shallow(); + `, + }, + { + code: ` + // case: nothing related to Testing Library at all (require version) + const { shallow } = require('enzyme'); + + const wrapper = shallow(); + `, + }, + { + code: ` + // case: render imported from other than custom module + import { render } from '@somewhere/else' + + const utils = render(); + `, + settings: { + 'testing-library/utils-module': 'test-utils', + }, + }, + { + code: ` + // case: render imported from other than custom module (require version) + const { render } = require('@somewhere/else') + + const utils = render(); + `, + settings: { + 'testing-library/utils-module': 'test-utils', + }, + }, + { + code: ` + // case: prevent import which should trigger an error since it's imported + // from other than settings custom module + import { foo } from 'report-me' + `, + settings: { + 'testing-library/utils-module': 'test-utils', + }, + }, + { + code: ` + // case: prevent import which should trigger an error since it's imported + // from other than settings custom module (require version) + const { foo } = require('report-me') + `, + settings: { + 'testing-library/utils-module': 'test-utils', + }, + }, + { + code: ` + // case: import custom module forced to be reported without custom module setting + import { foo } from 'custom-module-forced-report' + `, + }, + + // Test Cases for user-event imports + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import userEvent from 'somewhere-else' + userEvent.click(element) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import '@testing-library/user-event' + userEvent.click() + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { click } from '@testing-library/user-event' + userEvent.click() + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import * as incorrect from '@testing-library/user-event' + userEvent.click() + `, + }, + + // Test Cases for renders + { + code: ` + // case: aggressive render enabled - method not containing "render" + import { somethingElse } from '@somewhere/else' + + const utils = somethingElse() + `, + }, + { + settings: { 'testing-library/custom-renders': ['renderWithRedux'] }, + code: ` + // case: aggressive render disabled - method not matching valid render + import { customRender } from '@somewhere/else' + + const utils = customRender() + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + // case: aggressive render enabled, but module disabled - not coming from TL + import { render } from 'somewhere-else' + + const utils = render() + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case (render util): aggressive reporting disabled - method with same name + // as TL method but not coming from TL module is valid + import { render as testingLibraryRender } from 'test-utils' + import { render } from 'somewhere-else' + + const utils = render() + `, + }, + + // Test Cases for presence/absence assertions + // cases: asserts not related to presence/absence + 'expect(element).toBeDisabled()', + 'expect(element).toBeEnabled()', + + // cases: presence/absence matcher not related to assert + 'element.toBeInTheDocument()', + 'element.not.toBeInTheDocument()', + + // cases: weird scenarios to check guard against parent nodes + 'expect(element).not()', + 'expect(element).not()', + + // Test Cases for Queries and Aggressive Queries Reporting + { + code: ` + // case: custom method not matching "getBy*" variant pattern + getSomeElement('button') + `, + }, + { + code: ` + // case: custom method not matching "getBy*" variant pattern using within + within(container).getSomeElement('button') + `, + }, + { + code: ` + // case: custom method not matching "queryBy*" variant pattern + querySomeElement('button') + `, + }, + { + code: ` + // case: custom method not matching "queryBy*" variant pattern using within + within(container).querySomeElement('button') + `, + }, + { + code: ` + // case: custom method not matching "findBy*" variant pattern + findSomeElement('button') + `, + }, + { + code: ` + // case: custom method not matching "findBy*" variant pattern using within + within(container).findSomeElement('button') + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: built-in "getBy*" query not reported because custom module not imported + import { render } from 'other-module' + getByRole('button') + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: built-in "getBy*" query not reported because custom module not imported using within + import { render } from 'other-module' + within(container).getByRole('button') + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: built-in "queryBy*" query not reported because custom module not imported + import { render } from 'other-module' + queryByRole('button') + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: built-in "queryBy*" query not reported because custom module not imported using within + import { render } from 'other-module' + within(container).queryByRole('button') + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: built-in "findBy*" query not reported because custom module not imported + import { render } from 'other-module' + findByRole('button') + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: built-in "findBy*" query not reported because custom module not imported using within + import { render } from 'other-module' + within(container).findByRole('button') + `, + }, + + // Test Cases for async utils + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { waitFor } from 'some-other-library'; + test( + 'aggressive reporting disabled - util waitFor not related to testing library is valid', + () => { waitFor() } + ); + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case (async util): aggressive reporting disabled - method with same name + // as TL method but not coming from TL module is valid + import { waitFor as testingLibraryWaitFor } from 'test-utils' + import { waitFor } from 'somewhere-else' + + test('this should not be reported', () => { + waitFor() + }); + `, + }, + + // Test Cases for all settings mixed + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: matching custom settings + import { render } from 'other-utils' + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + // case: aggressive module disabled and render coming from non-related module + import * as somethingElse from '@somewhere/else' + import { render } from '@testing-library/react' + + // somethingElse.render is not coming from any module related to TL + const utils = somethingElse.render() + `, + }, + + // Weird edge cases + `(window as any).__THING = false;`, + `thing.method.lastCall.args[0]();`, + + `// edge case when setting jest-dom up in jest config file - using require + require('@testing-library/jest-dom') + + foo() + `, + + `// edge case when setting jest-dom up in jest config file - using import + import '@testing-library/jest-dom' + + foo() + `, + ], + invalid: [ + // Test Cases for Imports + { + code: ` + // case: import module forced to be reported + import { foo } from 'report-me' + `, + errors: [{ line: 3, column: 7, messageId: 'fakeError' }], + }, + { + code: ` + // case: render imported from any module by default (aggressive reporting) + import { render } from '@somewhere/else' + import { somethingElse } from 'another-module' + + const utils = render(); + `, + errors: [ + { + line: 6, + column: 21, + messageId: 'renderError', + }, + ], + }, + { + code: ` + // case: render imported from Testing Library module + import { render } from '@testing-library/react' + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + errors: [ + { + line: 7, + column: 21, + messageId: 'renderError', + }, + ], + }, + { + code: ` + // case: render imported from Testing Library module (require version) + const { render } = require('@testing-library/react') + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + errors: [ + { + line: 7, + column: 21, + messageId: 'renderError', + }, + ], + }, + { + code: ` + // case: render imported from settings custom module + import { render } from 'test-utils' + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + settings: { + 'testing-library/utils-module': 'test-utils', + }, + errors: [ + { + line: 7, + column: 21, + messageId: 'renderError', + }, + ], + }, + { + code: ` + // case: render imported from settings custom module (require version) + const { render } = require('test-utils') + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + settings: { + 'testing-library/utils-module': 'test-utils', + }, + errors: [ + { + line: 7, + column: 21, + messageId: 'renderError', + }, + ], + }, + { + code: ` + // case: render imported from Testing Library module with + // settings custom module + import { render } from '@testing-library/react' + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + settings: { + 'testing-library/utils-module': 'test-utils', + }, + errors: [ + { + line: 8, + column: 21, + messageId: 'renderError', + }, + ], + }, + { + code: ` + // case: render imported from Testing Library module with + // settings custom module (require version) + const { render } = require('@testing-library/react') + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + settings: { + 'testing-library/utils-module': 'test-utils', + }, + errors: [ + { + line: 8, + column: 21, + messageId: 'renderError', + }, + ], + }, + { + settings: { + 'testing-library/utils-module': 'custom-module-forced-report', + }, + code: ` + // case: import custom module forced to be reported with custom module setting + import { foo } from 'custom-module-forced-report' + `, + errors: [{ line: 3, column: 7, messageId: 'fakeError' }], + }, + + // Test Cases for user-event imports + { + code: ` + import userEvent from 'somewhere-else' + userEvent.click(element) + `, + errors: [{ line: 3, column: 17, messageId: 'userEventError' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import userEvent from '@testing-library/user-event' + userEvent.click(element) + `, + errors: [{ line: 3, column: 17, messageId: 'userEventError' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import renamed from '@testing-library/user-event' + renamed.click(element) + `, + errors: [{ line: 3, column: 15, messageId: 'userEventError' }], + }, + { + code: ` + const userEvent = require('somewhere-else') + userEvent.click(element) + `, + errors: [{ line: 3, column: 17, messageId: 'userEventError' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + const userEvent = require('@testing-library/user-event') + userEvent.click(element) + `, + errors: [{ line: 3, column: 17, messageId: 'userEventError' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + const renamed = require('@testing-library/user-event') + renamed.click(element) + `, + errors: [{ line: 3, column: 15, messageId: 'userEventError' }], + }, + + // Test Cases for renders + { + code: ` + // case: aggressive render enabled - Testing Library render + import { render } from '@testing-library/react' + + const utils = render() + `, + errors: [{ line: 5, column: 21, messageId: 'renderError' }], + }, + { + code: ` + // case: aggressive render enabled - Testing Library render wildcard imported + import * as rtl from '@testing-library/react' + + const utils = rtl.render() + `, + errors: [{ line: 5, column: 25, messageId: 'renderError' }], + }, + { + code: ` + // case: aggressive render enabled - any method containing "render" + import { someRender } from '@somewhere/else' + + const utils = someRender() + `, + errors: [{ line: 5, column: 21, messageId: 'renderError' }], + }, + { + settings: { 'testing-library/custom-renders': ['customRender'] }, + code: ` + // case: aggressive render disabled - Testing Library render + import { render } from '@testing-library/react' + + const utils = render() + `, + errors: [{ line: 5, column: 21, messageId: 'renderError' }], + }, + { + settings: { + 'testing-library/custom-renders': ['customRender', 'renderWithRedux'], + }, + code: ` + // case: aggressive render disabled - valid custom render + import { customRender } from 'test-utils' + + const utils = customRender() + `, + errors: [{ line: 5, column: 21, messageId: 'renderError' }], + }, + { + settings: { + 'testing-library/custom-renders': ['customRender', 'renderWithRedux'], + }, + code: ` + // case: aggressive render disabled - default render from custom module + import { render } from 'test-utils' + + const utils = render() + `, + errors: [{ line: 5, column: 21, messageId: 'renderError' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + // case: aggressive module disabled and render wildcard-imported from related module + import * as rtl from '@testing-library/react' + + const utils = rtl.render() + `, + errors: [{ line: 5, column: 25, messageId: 'renderError' }], + }, + + // Test Cases for presence/absence assertions + { + code: ` + // case: presence matcher .toBeInTheDocument forced to be reported + expect(element).toBeInTheDocument() + `, + errors: [{ line: 3, column: 7, messageId: 'presenceAssertError' }], + }, + { + code: ` + // case: absence matcher .not.toBeInTheDocument forced to be reported + expect(element).not.toBeInTheDocument() + `, + errors: [{ line: 3, column: 7, messageId: 'absenceAssertError' }], + }, + { + code: ` + // case: presence matcher .not.toBeNull forced to be reported + expect(element).not.toBeNull() + `, + errors: [{ line: 3, column: 7, messageId: 'presenceAssertError' }], + }, + { + code: ` + // case: absence matcher .toBeNull forced to be reported + expect(element).toBeNull() + `, + errors: [{ line: 3, column: 7, messageId: 'absenceAssertError' }], + }, + + // Test Cases for async utils + { + code: ` + import { waitFor } from 'test-utils'; + test( + 'aggressive reporting enabled - util waitFor reported no matter where is coming from', + () => { waitFor() } + ); + `, + errors: [ + { + line: 5, + column: 19, + messageId: 'asyncUtilError', + data: { utilName: 'waitFor' }, + }, + ], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { waitFor } from 'test-utils'; + test( + 'aggressive reporting disabled - util waitFor related to testing library', + () => { waitFor() } + ); + `, + errors: [ + { + line: 5, + column: 19, + messageId: 'asyncUtilError', + data: { utilName: 'waitFor' }, + }, + ], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: aggressive reporting disabled - waitFor from wildcard import related to TL + import * as tl from 'test-utils' + tl.waitFor(() => {}) + `, + errors: [ + { + line: 4, + column: 12, + messageId: 'asyncUtilError', + data: { utilName: 'waitFor' }, + }, + ], + }, + + // Test Cases for Queries and Aggressive Queries Reporting + { + code: ` + // case: built-in "getBy*" query reported without import (aggressive reporting) + getByRole('button') + `, + errors: [{ line: 3, column: 7, messageId: 'getByError' }], + }, + { + code: ` + // case: built-in "getBy*" query reported without import using within (aggressive reporting) + within(container).getByRole('button') + `, + errors: [{ line: 3, column: 25, messageId: 'getByError' }], + }, + { + code: ` + // case: built-in "queryBy*" query reported without import (aggressive reporting) + queryByRole('button') + `, + errors: [{ line: 3, column: 7, messageId: 'queryByError' }], + }, + { + code: ` + // case: built-in "queryBy*" query reported without import using within (aggressive reporting) + within(container).queryByRole('button') + `, + errors: [{ line: 3, column: 25, messageId: 'queryByError' }], + }, + { + code: ` + // case: built-in "findBy*" query reported without import (aggressive reporting) + findByRole('button') + `, + errors: [{ line: 3, column: 7, messageId: 'findByError' }], + }, + { + code: ` + // case: built-in "findBy*" query reported without import using within (aggressive reporting) + within(container).findByRole('button') + `, + errors: [{ line: 3, column: 25, messageId: 'findByError' }], + }, + { + code: ` + // case: custom "queryBy*" query reported without import (aggressive reporting) + queryByIcon('search') + `, + errors: [{ line: 3, column: 7, messageId: 'customQueryError' }], + }, + { + code: ` + // case: custom "queryBy*" query reported without import using within (aggressive reporting) + within(container).queryByIcon('search') + `, + errors: [{ line: 3, column: 25, messageId: 'customQueryError' }], + }, + { + code: ` + // case: custom "findBy*" query reported without import (aggressive reporting) + findByIcon('search') + `, + errors: [{ line: 3, column: 7, messageId: 'customQueryError' }], + }, + { + code: ` + // case: custom "findBy*" query reported without import using within (aggressive reporting) + within(container).findByIcon('search') + `, + errors: [{ line: 3, column: 25, messageId: 'customQueryError' }], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: built-in "getBy*" query reported with custom module + Testing Library package import + import { render } from '@testing-library/react' + getByRole('button') + `, + errors: [{ line: 4, column: 7, messageId: 'getByError' }], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: built-in "getBy*" query reported with custom module + custom module import + import { render } from 'test-utils' + getByRole('button') + `, + errors: [{ line: 4, column: 7, messageId: 'getByError' }], + }, + + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: custom "getBy*" query reported with custom module + Testing Library package import + import { render } from '@testing-library/react' + getByIcon('search') + `, + errors: [{ line: 4, column: 7, messageId: 'customQueryError' }], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: custom "getBy*" query reported with custom module + custom module import + import { render } from 'test-utils' + getByIcon('search') + `, + errors: [{ line: 4, column: 7, messageId: 'customQueryError' }], + }, + + // Test Cases for all settings mixed + { + settings: { + 'testing-library/custom-renders': ['customRender', 'renderWithRedux'], + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: aggressive reporting disabled - matching all custom settings + import { renderWithRedux, waitFor, screen } from 'test-utils' + + const { getByRole } = renderWithRedux() + const el = getByRole('button') + waitFor(() => {}) + `, + errors: [ + { line: 5, column: 29, messageId: 'renderError' }, + { line: 6, column: 18, messageId: 'getByError' }, + { + line: 7, + column: 7, + messageId: 'asyncUtilError', + data: { utilName: 'waitFor' }, + }, + ], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: matching all custom settings + import { render } from 'test-utils' + import { somethingElse } from 'another-module' + const foo = require('bar') + + const utils = render(); + `, + errors: [{ line: 7, column: 21, messageId: 'renderError' }], + }, + ], +}); diff --git a/tests/fake-rule.ts b/tests/fake-rule.ts new file mode 100644 index 00000000..e76cb61e --- /dev/null +++ b/tests/fake-rule.ts @@ -0,0 +1,123 @@ +/** + * @file Fake rule to be able to test createTestingLibraryRule and + * detectTestingLibraryUtils properly + */ +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { createTestingLibraryRule } from '../lib/create-testing-library-rule'; + +export const RULE_NAME = 'fake-rule'; +type Options = []; +type MessageIds = + | 'fakeError' + | 'renderError' + | 'asyncUtilError' + | 'getByError' + | 'queryByError' + | 'findByError' + | 'customQueryError' + | 'userEventError' + | 'presenceAssertError' + | 'absenceAssertError'; + +export default createTestingLibraryRule({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: 'Fake rule to test rule maker and detection helpers', + category: 'Possible Errors', + recommended: false, + }, + messages: { + fakeError: 'fake error reported', + renderError: 'some error related to render util reported', + asyncUtilError: + 'some error related to {{ utilName }} async util reported', + getByError: 'some error related to getBy reported', + queryByError: 'some error related to queryBy reported', + findByError: 'some error related to findBy reported', + customQueryError: 'some error related to a customQuery reported', + userEventError: 'some error related to userEvent reported', + presenceAssertError: 'some error related to presence assert reported', + absenceAssertError: 'some error related to absence assert reported', + }, + schema: [], + }, + defaultOptions: [], + create(context, _, helpers) { + const reportCallExpressionIdentifier = (node: TSESTree.Identifier) => { + // force "render" to be reported + if (helpers.isRenderUtil(node)) { + return context.report({ node, messageId: 'renderError' }); + } + + // force async utils to be reported + if (helpers.isAsyncUtil(node)) { + return context.report({ + node, + messageId: 'asyncUtilError', + data: { utilName: node.name }, + }); + } + + if (helpers.isUserEventMethod(node)) { + return context.report({ node, messageId: 'userEventError' }); + } + + // force queries to be reported + if (helpers.isCustomQuery(node)) { + return context.report({ node, messageId: 'customQueryError' }); + } + + if (helpers.isGetQueryVariant(node)) { + return context.report({ node, messageId: 'getByError' }); + } + + if (helpers.isQueryQueryVariant(node)) { + return context.report({ node, messageId: 'queryByError' }); + } + + if (helpers.isFindQueryVariant(node)) { + return context.report({ node, messageId: 'findByError' }); + } + }; + + const reportMemberExpression = (node: TSESTree.MemberExpression) => { + if (helpers.isPresenceAssert(node)) { + return context.report({ node, messageId: 'presenceAssertError' }); + } + + if (helpers.isAbsenceAssert(node)) { + return context.report({ node, messageId: 'absenceAssertError' }); + } + }; + + const reportImportDeclaration = (node: TSESTree.ImportDeclaration) => { + // This is just to check that defining an `ImportDeclaration` doesn't + // override `ImportDeclaration` from `detectTestingLibraryUtils` + if (node.source.value === 'report-me') { + context.report({ node, messageId: 'fakeError' }); + } + }; + + return { + 'CallExpression Identifier': reportCallExpressionIdentifier, + MemberExpression: reportMemberExpression, + ImportDeclaration: reportImportDeclaration, + 'Program:exit'() { + const importNode = helpers.getCustomModuleImportNode(); + const importName = helpers.getCustomModuleImportName(); + if (!importNode) { + return; + } + + if (importName === 'custom-module-forced-report') { + context.report({ + node: importNode, + messageId: 'fakeError', + }); + } + }, + }; + }, +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 3dab08f8..6e4eddae 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -6,17 +6,19 @@ import * as path from 'path'; const rulesModules = fs.readdirSync(path.join(__dirname, '../lib/rules')); it('should export all available rules', () => { - const availableRules = rulesModules.map(module => module.replace('.ts', '')); + const availableRules = rulesModules.map((module) => + module.replace('.ts', '') + ); expect(Object.keys(rules)).toEqual(availableRules); }); -it.each(['recommended', 'angular', 'react', 'vue'])( +it.each(['dom', 'angular', 'react', 'vue'])( 'should export proper "%s" config', - configName => { + (configName) => { expect(configs[configName]).toMatchSnapshot(); // make sure all enabled rules start by "testing-library/" prefix - Object.keys(configs[configName].rules).forEach(ruleEnabled => { + Object.keys(configs[configName].rules).forEach((ruleEnabled) => { expect(ruleEnabled).toMatch(/^testing-library\/.+$/); }); } diff --git a/tests/lib/rules/await-async-query.test.ts b/tests/lib/rules/await-async-query.test.ts index 8e666cf7..fa56dfd7 100644 --- a/tests/lib/rules/await-async-query.test.ts +++ b/tests/lib/rules/await-async-query.test.ts @@ -3,6 +3,8 @@ import { createRuleTester } from '../test-utils'; import rule, { RULE_NAME } from '../../../lib/rules/await-async-query'; import { ASYNC_QUERIES_COMBINATIONS, + ASYNC_QUERIES_VARIANTS, + combineQueries, SYNC_QUERIES_COMBINATIONS, } from '../../../lib/utils'; @@ -25,16 +27,19 @@ function createTestCode({ code, isAsync = true }: TestCode) { interface TestCaseParams { isAsync?: boolean; combinations?: string[]; - errors?: TestCaseError<'awaitAsyncQuery'>[]; + errors?: TestCaseError<'awaitAsyncQuery' | 'asyncQueryWrapper'>[]; } function createTestCase( getTest: ( query: string ) => string | { code: string; errors?: TestCaseError<'awaitAsyncQuery'>[] }, - { combinations = ASYNC_QUERIES_COMBINATIONS, isAsync }: TestCaseParams = {} + { + combinations = ALL_ASYNC_COMBINATIONS_TO_TEST, + isAsync, + }: TestCaseParams = {} ) { - return combinations.map(query => { + return combinations.map((query) => { const test = getTest(query); return typeof test === 'string' @@ -46,19 +51,30 @@ function createTestCase( }); } +const CUSTOM_ASYNC_QUERIES_COMBINATIONS = combineQueries( + ASYNC_QUERIES_VARIANTS, + ['ByIcon', 'ByButton'] +); + +// built-in queries + custom queries +const ALL_ASYNC_COMBINATIONS_TO_TEST = [ + ...ASYNC_QUERIES_COMBINATIONS, + ...CUSTOM_ASYNC_QUERIES_COMBINATIONS, +]; + ruleTester.run(RULE_NAME, rule, { valid: [ // async queries declaration from render functions are valid - ...createTestCase(query => `const { ${query} } = render()`, { + ...createTestCase((query) => `const { ${query} } = render()`, { isAsync: false, }), // async screen queries declaration are valid - ...createTestCase(query => `await screen.${query}('foo')`), + ...createTestCase((query) => `await screen.${query}('foo')`), // async queries are valid with await operator ...createTestCase( - query => ` + (query) => ` doSomething() await ${query}('foo') ` @@ -66,7 +82,7 @@ ruleTester.run(RULE_NAME, rule, { // async queries are valid when saved in a variable with await operator ...createTestCase( - query => ` + (query) => ` doSomething() const foo = await ${query}('foo') expect(foo).toBeInTheDocument(); @@ -75,15 +91,7 @@ ruleTester.run(RULE_NAME, rule, { // async queries are valid when saved in a promise variable immediately resolved ...createTestCase( - query => ` - const promise = ${query}('foo') - await promise - ` - ), - - // async queries are valid when saved in a promise variable resolved by an await operator - ...createTestCase( - query => ` + (query) => ` const promise = ${query}('foo') await promise ` @@ -91,7 +99,7 @@ ruleTester.run(RULE_NAME, rule, { // async queries are valid when used with then method ...createTestCase( - query => ` + (query) => ` ${query}('foo').then(() => { done() }) @@ -100,29 +108,81 @@ ruleTester.run(RULE_NAME, rule, { // async queries are valid with promise in variable resolved by then method ...createTestCase( - query => ` + (query) => ` const promise = ${query}('foo') promise.then((done) => done()) ` ), + // async queries are valid when wrapped within Promise.all + await expression + ...createTestCase( + (query) => ` + doSomething() + + await Promise.all([ + ${query}('foo'), + ${query}('bar'), + ]); + ` + ), + + // async queries are valid when wrapped within Promise.all + then chained + ...createTestCase( + (query) => ` + doSomething() + + Promise.all([ + ${query}('foo'), + ${query}('bar'), + ]).then() + ` + ), + + // async queries are valid when wrapped within Promise.allSettled + await expression + ...createTestCase( + (query) => ` + doSomething() + + await Promise.allSettled([ + ${query}('foo'), + ${query}('bar'), + ]); + ` + ), + + // async queries are valid when wrapped within Promise.allSettled + then chained + ...createTestCase( + (query) => ` + doSomething() + + Promise.allSettled([ + ${query}('foo'), + ${query}('bar'), + ]).then() + ` + ), + // async queries are valid with promise returned in arrow function - ...createTestCase(query => `const anArrowFunction = () => ${query}('foo')`), + ...createTestCase( + (query) => `const anArrowFunction = () => ${query}('foo')` + ), // async queries are valid with promise returned in regular function - ...createTestCase(query => `function foo() { return ${query}('foo') }`), + ...createTestCase((query) => `function foo() { return ${query}('foo') }`), - // async queries are valid with promise in variable and returned in regular functio + // async queries are valid with promise in variable and returned in regular function ...createTestCase( - query => ` - const promise = ${query}('foo') - return promise + (query) => ` + async function queryWrapper() { + const promise = ${query}('foo') + return promise + } ` ), // sync queries are valid ...createTestCase( - query => ` + (query) => ` doSomething() ${query}('foo') `, @@ -131,7 +191,7 @@ ruleTester.run(RULE_NAME, rule, { // async queries with resolves matchers are valid ...createTestCase( - query => ` + (query) => ` expect(${query}("foo")).resolves.toBe("bar") expect(wrappedQuery(${query}("foo"))).resolves.toBe("bar") ` @@ -139,22 +199,15 @@ ruleTester.run(RULE_NAME, rule, { // async queries with rejects matchers are valid ...createTestCase( - query => ` + (query) => ` expect(${query}("foo")).rejects.toBe("bar") expect(wrappedQuery(${query}("foo"))).rejects.toBe("bar") ` ), - // non existing queries are valid - createTestCode({ - code: ` - doSomething() - const foo = findByNonExistingTestingLibraryQuery('foo') - `, - }), - - // unresolved async queries are valid if there are no imports from a testing library module - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ + // unresolved async queries with aggressive reporting opted-out are valid + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + settings: { 'testing-library/utils-module': 'test-utils' }, code: ` import { render } from "another-library" @@ -163,39 +216,201 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), + + // non-matching query is valid + ` + test('An valid example test', async () => { + const example = findText("my example") + }) + `, + + // unhandled promise from non-matching query is valid + ` + async function findButton() { + const element = findByText('outer element') + return somethingElse(element) + } + + test('An valid example test', async () => { + // findButton doesn't match async query pattern + const button = findButton() + }) + `, + + // edge case for coverage + // return non-matching query and other than Identifier or CallExpression + ` + async function someSetup() { + const element = await findByText('outer element') + return element ? findSomethingElse(element) : null + } + + test('An valid example test', async () => { + someSetup() + }) + `, + + // edge case for coverage + // valid async query usage without any function defined + // so there is no innermost function scope found + `const element = await findByRole('button')`, ], invalid: [ - // async queries without await operator or then method are not valid - ...createTestCase(query => ({ - code: ` + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( + (query) => + ({ + code: `// async queries without await operator or then method are not valid + import { render } from '@testing-library/react' + + test("An example test", async () => { doSomething() const foo = ${query}('foo') + }); `, - errors: [{ messageId: 'awaitAsyncQuery' }], - })), + errors: [{ messageId: 'awaitAsyncQuery', line: 6, column: 21 }], + } as const) + ), + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( + (query) => + ({ + code: `// async screen queries without await operator or then method are not valid + import { render } from '@testing-library/react' - // async screen queries without await operator or then method are not valid - ...createTestCase(query => ({ - code: `screen.${query}('foo')`, - errors: [{ messageId: 'awaitAsyncQuery' }], - })), + test("An example test", async () => { + screen.${query}('foo') + }); + `, + errors: [ + { + messageId: 'awaitAsyncQuery', + line: 5, + column: 16, + data: { name: query }, + }, + ], + } as const) + ), + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( + (query) => + ({ + code: ` + import { render } from '@testing-library/react' - ...createTestCase(query => ({ - code: ` + test("An example test", async () => { + doSomething() + const foo = ${query}('foo') + }); + `, + errors: [ + { + messageId: 'awaitAsyncQuery', + line: 6, + column: 21, + data: { name: query }, + }, + ], + } as const) + ), + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( + (query) => + ({ + code: ` + import { render } from '@testing-library/react' + + test("An example test", async () => { const foo = ${query}('foo') expect(foo).toBeInTheDocument() expect(foo).toHaveAttribute('src', 'bar'); + }); `, - errors: [ - { - line: 5, - messageId: 'awaitAsyncQuery', - data: { - name: query, - }, - }, - ], - })), + errors: [ + { + messageId: 'awaitAsyncQuery', + line: 5, + column: 21, + data: { name: query }, + }, + ], + } as const) + ), + + // unresolved async queries are not valid (aggressive reporting) + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( + (query) => + ({ + code: ` + import { render } from "another-library" + + test('An example test', async () => { + const example = ${query}("my example") + }) + `, + errors: [{ messageId: 'awaitAsyncQuery', line: 5, column: 27 }], + } as const) + ), + + // unhandled promise from async query function wrapper is invalid + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( + (query) => + ({ + code: ` + function queryWrapper() { + doSomethingElse(); + + return screen.${query}('foo') + } + + test("An invalid example test", () => { + const element = queryWrapper() + }) + + test("An valid example test", async () => { + const element = await queryWrapper() + }) + `, + errors: [{ messageId: 'asyncQueryWrapper', line: 9, column: 27 }], + } as const) + ), + // unhandled promise from async query arrow function wrapper is invalid + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( + (query) => + ({ + code: ` + const queryWrapper = () => { + doSomethingElse(); + + return ${query}('foo') + } + + test("An invalid example test", () => { + const element = queryWrapper() + }) + + test("An valid example test", async () => { + const element = await queryWrapper() + }) + `, + errors: [{ messageId: 'asyncQueryWrapper', line: 9, column: 27 }], + } as const) + ), + // unhandled promise implicitly returned from async query arrow function wrapper is invalid + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( + (query) => + ({ + code: ` + const queryWrapper = () => screen.${query}('foo') + + test("An invalid example test", () => { + const element = queryWrapper() + }) + + test("An valid example test", async () => { + const element = await queryWrapper() + }) + `, + errors: [{ messageId: 'asyncQueryWrapper', line: 5, column: 27 }], + } as const) + ), ], }); diff --git a/tests/lib/rules/await-async-utils.test.ts b/tests/lib/rules/await-async-utils.test.ts index eb58c9d6..99f39ccb 100644 --- a/tests/lib/rules/await-async-utils.test.ts +++ b/tests/lib/rules/await-async-utils.test.ts @@ -6,7 +6,7 @@ const ruleTester = createRuleTester(); ruleTester.run(RULE_NAME, rule, { valid: [ - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util directly waited with await operator is valid', async () => { @@ -16,7 +16,7 @@ ruleTester.run(RULE_NAME, rule, { `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util promise saved in var and waited with await operator is valid', async () => { @@ -27,7 +27,7 @@ ruleTester.run(RULE_NAME, rule, { `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util directly chained with then is valid', () => { @@ -37,7 +37,7 @@ ruleTester.run(RULE_NAME, rule, { `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util promise saved in var and chained with then is valid', () => { @@ -48,7 +48,7 @@ ruleTester.run(RULE_NAME, rule, { `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util directly returned in arrow function is valid', async () => { @@ -60,7 +60,7 @@ ruleTester.run(RULE_NAME, rule, { `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util explicitly returned in arrow function is valid', async () => { @@ -73,7 +73,7 @@ ruleTester.run(RULE_NAME, rule, { `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util returned in regular function is valid', async () => { @@ -86,7 +86,7 @@ ruleTester.run(RULE_NAME, rule, { `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('${asyncUtil} util promise saved in var and returned in function is valid', async () => { @@ -102,28 +102,38 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` import { ${asyncUtil} } from 'some-other-library'; - test('util "${asyncUtil}" which is not related to testing library is valid', async () => { + test( + 'aggressive reporting disabled - util "${asyncUtil}" which is not related to testing library is valid', + async () => { doSomethingElse(); ${asyncUtil}(); }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` import * as asyncUtils from 'some-other-library'; - test('util "asyncUtils.${asyncUtil}" which is not related to testing library is valid', async () => { + test( + 'aggressive reporting disabled - util "asyncUtils.${asyncUtil}" which is not related to testing library is valid', + async () => { doSomethingElse(); asyncUtils.${asyncUtil}(); }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; - test('${asyncUtil} util used in with Promise.all() does not trigger an error', async () => { + test('${asyncUtil} util used in with Promise.all() is valid', async () => { await Promise.all([ ${asyncUtil}(callback1), ${asyncUtil}(callback2), @@ -131,10 +141,10 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; - test('${asyncUtil} util used in with Promise.all() with an await does not trigger an error', async () => { + test('${asyncUtil} util used in with Promise.all() with an await is valid', async () => { await Promise.all([ await ${asyncUtil}(callback1), await ${asyncUtil}(callback2), @@ -142,10 +152,10 @@ ruleTester.run(RULE_NAME, rule, { }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; - test('${asyncUtil} util used in with Promise.all() with ".then" does not trigger an error', async () => { + test('${asyncUtil} util used in with Promise.all() with ".then" is valid', async () => { Promise.all([ ${asyncUtil}(callback1), ${asyncUtil}(callback2), @@ -162,7 +172,7 @@ ruleTester.run(RULE_NAME, rule, { waitForElementToBeRemoved(() => document.querySelector('div.getOuttaHere')), ]) }); - ` + `, }, { code: ` @@ -174,70 +184,264 @@ ruleTester.run(RULE_NAME, rule, { }); `, }, - { + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` - test('util not related to testing library is valid', async () => { - doSomethingElse(); - waitNotRelatedToTestingLibrary(); + import { ${asyncUtil} } from '@testing-library/dom'; + test('${asyncUtil} util used in Promise.allSettled + await expression is valid', async () => { + await Promise.allSettled([ + ${asyncUtil}(callback1), + ${asyncUtil}(callback2), + ]); }); `, - }, + })), + ...ASYNC_UTILS.map((asyncUtil) => ({ + code: ` + import { ${asyncUtil} } from '@testing-library/dom'; + test('${asyncUtil} util used in Promise.allSettled + then method is valid', async () => { + Promise.allSettled([ + ${asyncUtil}(callback1), + ${asyncUtil}(callback2), + ]).then(() => {}) + }); + `, + })), + ...ASYNC_UTILS.map((asyncUtil) => ({ + code: ` + import { ${asyncUtil} } from '@testing-library/dom'; + + function waitForSomethingAsync() { + return ${asyncUtil}(() => somethingAsync()) + } + + test('handled promise from function wrapping ${asyncUtil} util is valid', async () => { + await waitForSomethingAsync() + }); + `, + })), { code: ` - test('using unrelated promises with Promise.all do not throw an error', async () => { - await Promise.all([ - someMethod(), + test('using unrelated promises with Promise.all is valid', async () => { + Promise.all([ + waitForNotRelatedToTestingLibrary(), promise1, await foo().then(() => baz()) ]) }) - ` - } + `, + }, + + // edge case for coverage + // valid async query usage without any function defined + // so there is no innermost function scope found + ` + import { waitFor } from '@testing-library/dom'; + test('edge case for no innermost function scope', () => { + const foo = waitFor + }) + `, ], invalid: [ - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import { ${asyncUtil} } from '@testing-library/dom'; - test('${asyncUtil} util not waited', () => { + test('${asyncUtil} util not waited is invalid', () => { doSomethingElse(); ${asyncUtil}(() => getByLabelText('email')); }); `, - errors: [{ line: 5, messageId: 'awaitAsyncUtil' }], - })), - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + errors: [ + { + line: 5, + column: 11, + messageId: 'awaitAsyncUtil', + data: { name: asyncUtil }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + import { ${asyncUtil} } from '@testing-library/dom'; + test('${asyncUtil} util not waited is invalid', () => { + doSomethingElse(); + const el = ${asyncUtil}(() => getByLabelText('email')); + }); + `, + errors: [ + { + line: 5, + column: 22, + messageId: 'awaitAsyncUtil', + data: { name: asyncUtil }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import * as asyncUtil from '@testing-library/dom'; - test('asyncUtil.${asyncUtil} util not waited', () => { + test('asyncUtil.${asyncUtil} util not handled is invalid', () => { doSomethingElse(); asyncUtil.${asyncUtil}(() => getByLabelText('email')); }); `, - errors: [{ line: 5, messageId: 'awaitAsyncUtil' }], - })), - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + errors: [ + { + line: 5, + column: 21, + messageId: 'awaitAsyncUtil', + data: { name: asyncUtil }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import { ${asyncUtil} } from '@testing-library/dom'; - test('${asyncUtil} util promise saved not waited', () => { + test('${asyncUtil} util promise saved not handled is invalid', () => { doSomethingElse(); const aPromise = ${asyncUtil}(() => getByLabelText('email')); }); `, - errors: [{ line: 5, column: 28, messageId: 'awaitAsyncUtil' }], - })), - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + errors: [ + { + line: 5, + column: 28, + messageId: 'awaitAsyncUtil', + data: { name: asyncUtil }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import { ${asyncUtil} } from '@testing-library/dom'; - test('several ${asyncUtil} utils not waited', () => { + test('several ${asyncUtil} utils not handled are invalid', () => { const aPromise = ${asyncUtil}(() => getByLabelText('username')); doSomethingElse(aPromise); ${asyncUtil}(() => getByLabelText('email')); }); `, - errors: [ - { line: 4, column: 28, messageId: 'awaitAsyncUtil' }, - { line: 6, column: 11, messageId: 'awaitAsyncUtil' }, - ], - })), + errors: [ + { + line: 4, + column: 28, + messageId: 'awaitAsyncUtil', + data: { name: asyncUtil }, + }, + { + line: 6, + column: 11, + messageId: 'awaitAsyncUtil', + data: { name: asyncUtil }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + import { ${asyncUtil}, render } from '@testing-library/dom'; + + function waitForSomethingAsync() { + return ${asyncUtil}(() => somethingAsync()) + } + + test('unhandled promise from function wrapping ${asyncUtil} util is invalid', async () => { + render() + waitForSomethingAsync() + }); + `, + errors: [ + { + messageId: 'asyncUtilWrapper', + line: 10, + column: 11, + data: { name: 'waitForSomethingAsync' }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + import { ${asyncUtil} } from 'some-other-library'; + test( + 'aggressive reporting - util "${asyncUtil}" which is not related to testing library is invalid', + async () => { + doSomethingElse(); + ${asyncUtil}(); + }); + `, + errors: [ + { + line: 7, + column: 11, + messageId: 'awaitAsyncUtil', + data: { name: asyncUtil }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + import { ${asyncUtil}, render } from '@testing-library/dom'; + + function waitForSomethingAsync() { + return ${asyncUtil}(() => somethingAsync()) + } + + test('unhandled promise from function wrapping ${asyncUtil} util is invalid', async () => { + render() + const el = waitForSomethingAsync() + }); + `, + errors: [ + { + messageId: 'asyncUtilWrapper', + line: 10, + column: 22, + data: { name: 'waitForSomethingAsync' }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + import * as asyncUtils from 'some-other-library'; + test( + 'aggressive reporting - util "asyncUtils.${asyncUtil}" which is not related to testing library is invalid', + async () => { + doSomethingElse(); + asyncUtils.${asyncUtil}(); + }); + `, + errors: [ + { + line: 7, + column: 22, + messageId: 'awaitAsyncUtil', + data: { name: asyncUtil }, + }, + ], + } as const) + ), ], }); diff --git a/tests/lib/rules/await-fire-event.test.ts b/tests/lib/rules/await-fire-event.test.ts index dda5ed5f..4918ebc0 100644 --- a/tests/lib/rules/await-fire-event.test.ts +++ b/tests/lib/rules/await-fire-event.test.ts @@ -3,88 +3,343 @@ import rule, { RULE_NAME } from '../../../lib/rules/await-fire-event'; const ruleTester = createRuleTester(); +const COMMON_FIRE_EVENT_METHODS: string[] = [ + 'click', + 'change', + 'focus', + 'blur', + 'keyDown', +]; + ruleTester.run(RULE_NAME, rule, { valid: [ - { - code: `fireEvent.click`, - }, - { - code: `async () => { - await fireEvent.click(getByText('Click me')) - } - `, - }, - { - code: `async () => { - await fireEvent.focus(getByLabelText('username')) - await fireEvent.blur(getByLabelText('username')) - } - `, - }, - { - code: `done => { - fireEvent.click(getByText('Click me')).then(() => { done() }) - } - `, - }, - { - code: `done => { - fireEvent.focus(getByLabelText('username')).then(() => { - fireEvent.blur(getByLabelText('username')).then(() => { done() }) + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('fire event method not called is valid', () => { + fireEvent.${fireEventMethod} + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('await promise from fire event method is valid', async () => { + await fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('await several promises from fire event methods is valid', async () => { + await fireEvent.${fireEventMethod}(getByLabelText('username')) + await fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('await promise kept in a var from fire event method is valid', async () => { + const promise = fireEvent.${fireEventMethod}(getByLabelText('username')) + await promise + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('chain then method to promise from fire event method is valid', async (done) => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + .then(() => { done() }) + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('chain then method to several promises from fire event methods is valid', async (done) => { + fireEvent.${fireEventMethod}(getByLabelText('username')).then(() => { + fireEvent.${fireEventMethod}(getByLabelText('username')).then(() => { done() }) }) - } - `, - }, - { - code: `() => { - return fireEvent.click(getByText('Click me')) - } - `, - }, - { - code: `() => fireEvent.click(getByText('Click me')) - `, - }, - { - code: `function clickUtil() { - doSomething() - return fireEvent.click(getByText('Click me')) - } - `, - }, + }) + `, + })), + `import { fireEvent } from '@testing-library/vue' + + test('fireEvent methods wrapped with Promise.all are valid', async () => { + await Promise.all([ + fireEvent.blur(getByText('Click me')), + fireEvent.click(getByText('Click me')), + ]) + }) + `, + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('return promise from fire event methods is valid', () => { + function triggerEvent() { + doSomething() + return fireEvent.${fireEventMethod}(getByLabelText('username')) + } + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('await promise returned from function wrapping fire event method is valid', () => { + function triggerEvent() { + doSomething() + return fireEvent.${fireEventMethod}(getByLabelText('username')) + } + + await triggerEvent() + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent } from 'somewhere-else' + test('unhandled promise from fire event not related to TL is valid', async () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + })), + ...COMMON_FIRE_EVENT_METHODS.map((fireEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent } from 'test-utils' + test('await promise from fire event method imported from custom module is valid', async () => { + await fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + })), + + // edge case for coverage: + // valid use case without call expression + // so there is no innermost function scope found + ` + import { fireEvent } from 'test-utils' + test('edge case for innermost function without call expression', async () => { + function triggerEvent() { + doSomething() + return fireEvent.focus(getByLabelText('username')) + } + + const reassignedFunction = triggerEvent + }) + `, ], invalid: [ - { - code: `() => { - fireEvent.click(getByText('Click me')) - } - `, - errors: [ - { - column: 19, - messageId: 'awaitFireEvent', - }, - ], - }, - { - code: `() => { - fireEvent.focus(getByLabelText('username')) - fireEvent.blur(getByLabelText('username')) - } - `, - errors: [ - { - line: 2, - column: 19, - messageId: 'awaitFireEvent', - }, - { - line: 3, - column: 19, - messageId: 'awaitFireEvent', - }, - ], - }, + ...COMMON_FIRE_EVENT_METHODS.map( + (fireEventMethod) => + ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('unhandled promise from fire event method is invalid', async () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 4, + column: 9, + endColumn: 19 + fireEventMethod.length, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + } as const) + ), + ...COMMON_FIRE_EVENT_METHODS.map( + (fireEventMethod) => + ({ + code: ` + import { fireEvent as testingLibraryFireEvent } from '@testing-library/vue' + test('unhandled promise from aliased fire event method is invalid', async () => { + testingLibraryFireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 4, + column: 9, + endColumn: 33 + fireEventMethod.length, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + } as const) + ), + ...COMMON_FIRE_EVENT_METHODS.map( + (fireEventMethod) => + ({ + code: ` + import * as testingLibrary from '@testing-library/vue' + test('unhandled promise from wildcard imported fire event method is invalid', async () => { + testingLibrary.fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 4, + column: 9, + endColumn: 34 + fireEventMethod.length, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + } as const) + ), + ...COMMON_FIRE_EVENT_METHODS.map( + (fireEventMethod) => + ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('several unhandled promises from fire event methods is invalid', async () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 4, + column: 9, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + { + line: 5, + column: 9, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + } as const) + ), + ...COMMON_FIRE_EVENT_METHODS.map( + (fireEventMethod) => + ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent } from '@testing-library/vue' + test('unhandled promise from fire event method with aggressive reporting opted-out is invalid', async () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 4, + column: 9, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + } as const) + ), + ...COMMON_FIRE_EVENT_METHODS.map( + (fireEventMethod) => + ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent } from 'test-utils' + test( + 'unhandled promise from fire event method imported from custom module with aggressive reporting opted-out is invalid', + () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 6, + column: 9, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + } as const) + ), + ...COMMON_FIRE_EVENT_METHODS.map( + (fireEventMethod) => + ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent } from '@testing-library/vue' + test( + 'unhandled promise from fire event method imported from default module with aggressive reporting opted-out is invalid', + () => { + fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 6, + column: 9, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + } as const) + ), + + ...COMMON_FIRE_EVENT_METHODS.map( + (fireEventMethod) => + ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test( + 'unhandled promise from fire event method kept in a var is invalid', + () => { + const promise = fireEvent.${fireEventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 6, + column: 25, + messageId: 'awaitFireEvent', + data: { name: fireEventMethod }, + }, + ], + } as const) + ), + ...COMMON_FIRE_EVENT_METHODS.map( + (fireEventMethod) => + ({ + code: ` + import { fireEvent } from '@testing-library/vue' + test('unhandled promise returned from function wrapping fire event method is invalid', () => { + function triggerEvent() { + doSomething() + return fireEvent.${fireEventMethod}(getByLabelText('username')) + } + + triggerEvent() + }) + `, + errors: [ + { + line: 9, + column: 9, + messageId: 'fireEventWrapper', + data: { name: fireEventMethod }, + }, + ], + } as const) + ), ], }); diff --git a/tests/lib/rules/consistent-data-testid.test.ts b/tests/lib/rules/consistent-data-testid.test.ts index 0e662969..dd0892f2 100644 --- a/tests/lib/rules/consistent-data-testid.test.ts +++ b/tests/lib/rules/consistent-data-testid.test.ts @@ -1,11 +1,7 @@ import { createRuleTester } from '../test-utils'; import rule, { RULE_NAME } from '../../../lib/rules/consistent-data-testid'; -const ruleTester = createRuleTester({ - ecmaFeatures: { - jsx: true, - }, -}); +const ruleTester = createRuleTester(); ruleTester.run(RULE_NAME, rule, { valid: [ @@ -203,7 +199,7 @@ ruleTester.run(RULE_NAME, rule, { options: [{ testIdPattern: 'error' }], errors: [ { - messageId: 'invalidTestId', + messageId: 'consistentDataTestId', data: { attr: 'data-testid', value: 'Awesome__CoolStuff', @@ -232,7 +228,7 @@ ruleTester.run(RULE_NAME, rule, { filename: '/my/cool/__tests__/Parent/index.js', errors: [ { - messageId: 'invalidTestId', + messageId: 'consistentDataTestId', data: { attr: 'data-testid', value: 'Nope', @@ -262,7 +258,7 @@ ruleTester.run(RULE_NAME, rule, { filename: '/my/cool/__tests__/Parent/index.js', errors: [ { - messageId: 'invalidTestId', + messageId: 'consistentDataTestId', data: { attr: 'my-custom-attr', value: 'WrongComponent__cool', @@ -292,7 +288,7 @@ ruleTester.run(RULE_NAME, rule, { filename: '/my/cool/__tests__/Parent/index.js', errors: [ { - messageId: 'invalidTestId', + messageId: 'consistentDataTestId', data: { attr: 'custom-attr', value: 'wrong', @@ -300,7 +296,7 @@ ruleTester.run(RULE_NAME, rule, { }, }, { - messageId: 'invalidTestId', + messageId: 'consistentDataTestId', data: { attr: 'another-custom-attr', value: 'wrong', @@ -329,7 +325,7 @@ ruleTester.run(RULE_NAME, rule, { filename: '/my/cool/__tests__/Parent/index.js', errors: [ { - messageId: 'invalidTestId', + messageId: 'consistentDataTestId', data: { attr: 'data-testid', value: 'WrongComponent__cool', diff --git a/tests/lib/rules/no-await-sync-events.test.ts b/tests/lib/rules/no-await-sync-events.test.ts index 1aef0bc6..63679459 100644 --- a/tests/lib/rules/no-await-sync-events.test.ts +++ b/tests/lib/rules/no-await-sync-events.test.ts @@ -1,10 +1,9 @@ import { createRuleTester } from '../test-utils'; import rule, { RULE_NAME } from '../../../lib/rules/no-await-sync-events'; -import { SYNC_EVENTS } from '../../../lib/utils'; const ruleTester = createRuleTester(); -const fireEventFunctions = [ +const FIRE_EVENT_FUNCTIONS = [ 'copy', 'cut', 'paste', @@ -89,7 +88,7 @@ const fireEventFunctions = [ 'gotPointerCapture', 'lostPointerCapture', ]; -const userEventFunctions = [ +const USER_EVENT_SYNC_FUNCTIONS = [ 'clear', 'click', 'dblClick', @@ -97,44 +96,44 @@ const userEventFunctions = [ 'deselectOptions', 'upload', // 'type', + // 'keyboard', 'tab', 'paste', 'hover', 'unhover', ]; -let eventFunctions: string[] = []; -SYNC_EVENTS.forEach(event => { - switch (event) { - case 'fireEvent': - eventFunctions = eventFunctions.concat(fireEventFunctions.map((f: string): string => `${event}.${f}`)); - break; - case 'userEvent': - eventFunctions = eventFunctions.concat(userEventFunctions.map((f: string): string => `${event}.${f}`)); - break; - default: - eventFunctions.push(`${event}.anyFunc`); - } -}); ruleTester.run(RULE_NAME, rule, { valid: [ - // sync events without await are valid - // userEvent.type() is an exception - ...eventFunctions.map(func => ({ + // sync fireEvents methods without await are valid + ...FIRE_EVENT_FUNCTIONS.map((func) => ({ + code: `() => { + fireEvent.${func}('foo') + } + `, + })), + // sync userEvent methods without await are valid + ...USER_EVENT_SYNC_FUNCTIONS.map((func) => ({ code: `() => { - ${func}('foo') + userEvent.${func}('foo') } `, })), { code: `() => { - userEvent.type('foo') + userEvent.type(element, 'foo') } `, }, { code: `() => { - await userEvent.type('foo', 'bar', {delay: 1234}) + userEvent.keyboard('foo') + } + `, + }, + { + code: `() => { + await userEvent.type(element, 'bar', {delay: 1234}) } `, }, @@ -144,32 +143,151 @@ ruleTester.run(RULE_NAME, rule, { } `, }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { fireEvent } from 'somewhere-else'; + test('should not report fireEvent.click() not related to Testing Library', async() => { + await fireEvent.click('foo'); + }); + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { fireEvent as renamedFireEvent } from 'somewhere-else'; + import renamedUserEvent from '@testing-library/user-event'; + import { fireEvent, userEvent } from 'somewhere-else' + + test('should not report unused renamed methods', async() => { + await fireEvent.click('foo'); + await userEvent.type('foo', 'bar', { delay: 5 }); + await userEvent.keyboard('foo', { delay: 5 }); + }); + `, + }, ], invalid: [ - // sync events with await operator are not valid - ...eventFunctions.map(func => ({ - code: ` + // sync fireEvent methods with await operator are not valid + ...FIRE_EVENT_FUNCTIONS.map( + (func) => + ({ + code: ` import { fireEvent } from '@testing-library/framework'; + test('should report fireEvent.${func} sync event awaited', async() => { + await fireEvent.${func}('foo'); + }); + `, + errors: [ + { + line: 4, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: `fireEvent.${func}` }, + }, + ], + } as const) + ), + // sync userEvent sync methods with await operator are not valid + ...USER_EVENT_SYNC_FUNCTIONS.map( + (func) => + ({ + code: ` import userEvent from '@testing-library/user-event'; - test('should report sync event awaited', async() => { - await ${func}('foo'); + test('should report userEvent.${func} sync event awaited', async() => { + await userEvent.${func}('foo'); }); `, - errors: [{ line: 5, messageId: 'noAwaitSyncEvents' },], - })), + errors: [ + { + line: 4, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: `userEvent.${func}` }, + }, + ], + } as const) + ), + + { + code: ` + import userEvent from '@testing-library/user-event'; + test('should report async events without delay awaited', async() => { + await userEvent.type('foo', 'bar'); + await userEvent.keyboard('foo'); + }); + `, + errors: [ + { + line: 4, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'userEvent.type' }, + }, + { + line: 5, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'userEvent.keyboard' }, + }, + ], + }, { code: ` import userEvent from '@testing-library/user-event'; - test('should report sync event awaited', async() => { - await userEvent.type('foo', 'bar', {hello: 1234}); - await userEvent.keyboard('foo', {hello: 1234}); + test('should report async events with 0 delay awaited', async() => { + await userEvent.type('foo', 'bar', { delay: 0 }); + await userEvent.keyboard('foo', { delay: 0 }); + }); + `, + errors: [ + { + line: 4, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'userEvent.type' }, + }, + { + line: 5, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'userEvent.keyboard' }, + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { fireEvent as renamedFireEvent } from 'test-utils'; + import renamedUserEvent from '@testing-library/user-event'; + + test('should report renamed invalid cases with Aggressive Reporting disabled', async() => { + await renamedFireEvent.click('foo'); + await renamedUserEvent.type('foo', 'bar', { delay: 0 }); + await renamedUserEvent.keyboard('foo', { delay: 0 }); }); `, errors: [ - { line: 4, messageId: 'noAwaitSyncEvents' }, - { line: 5, messageId: 'noAwaitSyncEvents' }, + { + line: 6, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'renamedFireEvent.click' }, + }, + { + line: 7, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'renamedUserEvent.type' }, + }, + { + line: 8, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'renamedUserEvent.keyboard' }, + }, ], - } + }, ], }); diff --git a/tests/lib/rules/no-await-sync-query.test.ts b/tests/lib/rules/no-await-sync-query.test.ts index bd3e62f0..a3a27751 100644 --- a/tests/lib/rules/no-await-sync-query.test.ts +++ b/tests/lib/rules/no-await-sync-query.test.ts @@ -10,55 +10,228 @@ const ruleTester = createRuleTester(); ruleTester.run(RULE_NAME, rule, { valid: [ // sync queries without await are valid - ...SYNC_QUERIES_COMBINATIONS.map(query => ({ + ...SYNC_QUERIES_COMBINATIONS.map((query) => ({ code: `() => { - ${query}('foo') + const element = ${query}('foo') + } + `, + })), + // custom sync queries without await are valid + `() => { + const element = getByIcon('search') + } + `, + `() => { + const element = queryByIcon('search') + } + `, + `() => { + const element = getAllByIcon('search') + } + `, + `() => { + const element = queryAllByIcon('search') + } + `, + `async () => { + await waitFor(() => { + getByText('search'); + }); + } + `, + // sync queries without await inside assert are valid + ...SYNC_QUERIES_COMBINATIONS.map((query) => ({ + code: `() => { + expect(${query}('foo')).toBeEnabled() } `, })), // async queries with await operator are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ + ...ASYNC_QUERIES_COMBINATIONS.map((query) => ({ code: `async () => { - await ${query}('foo') + const element = await ${query}('foo') } `, })), // async queries with then method are valid - ...ASYNC_QUERIES_COMBINATIONS.map(query => ({ + ...ASYNC_QUERIES_COMBINATIONS.map((query) => ({ code: `() => { ${query}('foo').then(() => {}); } `, })), + + // sync query awaited but not related to custom module is invalid but not reported + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { screen } from 'somewhere-else' + () => { + const element = await screen.getByRole('button') + } + `, + }, + + // https://github.com/testing-library/eslint-plugin-testing-library/issues/276 + ` + // sync query within call expression but not part of the callee + const chooseElementFromSomewhere = async (text, getAllByLabelText) => { + const someElement = getAllByLabelText(text)[0].parentElement; + // ... + await someOtherAsyncFunction(); + }; + + await chooseElementFromSomewhere('someTextToUseInAQuery', getAllByLabelText); + `, + + `// edge case for coverage: + // valid use case without call expression + // so there is no innermost function scope found + await test('edge case for no innermost function scope', () => { + const foo = getAllByLabelText + }) + `, + + `// edge case for coverage: CallExpression without deepest Identifier + await someList[0](); + `, + + `// element is removed + test('movie title no longer present in DOM', async () => { + await waitForElementToBeRemoved(() => queryByText('the mummy')) + }) + `, ], invalid: [ // sync queries with await operator are not valid - ...SYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { - await ${query}('foo') + ...SYNC_QUERIES_COMBINATIONS.map( + (query) => + ({ + code: `async () => { + const element = await ${query}('foo') } `, - errors: [ - { - messageId: 'noAwaitSyncQuery', - }, - ], - })), + errors: [ + { + messageId: 'noAwaitSyncQuery', + line: 2, + column: 31, + }, + ], + } as const) + ), + // custom sync queries with await operator are not valid + { + code: ` + async () => { + const element = await getByIcon('search') + } + `, + errors: [{ messageId: 'noAwaitSyncQuery', line: 3, column: 31 }], + }, + { + code: ` + async () => { + const element = await queryByIcon('search') + } + `, + errors: [{ messageId: 'noAwaitSyncQuery', line: 3, column: 31 }], + }, + { + code: ` + async () => { + const element = await screen.getAllByIcon('search') + } + `, + errors: [{ messageId: 'noAwaitSyncQuery', line: 3, column: 38 }], + }, + { + code: ` + async () => { + const element = await screen.queryAllByIcon('search') + } + `, + errors: [{ messageId: 'noAwaitSyncQuery', line: 3, column: 38 }], + }, + // sync queries with await operator inside assert are not valid + ...SYNC_QUERIES_COMBINATIONS.map( + (query) => + ({ + code: `async () => { + expect(await ${query}('foo')).toBeEnabled() + } + `, + errors: [ + { + messageId: 'noAwaitSyncQuery', + line: 2, + column: 22, + }, + ], + } as const) + ), // sync queries in screen with await operator are not valid - ...SYNC_QUERIES_COMBINATIONS.map(query => ({ - code: `async () => { - await screen.${query}('foo') + ...SYNC_QUERIES_COMBINATIONS.map( + (query) => + ({ + code: `async () => { + const element = await screen.${query}('foo') } `, - errors: [ - { - messageId: 'noAwaitSyncQuery', - }, - ], - })), + errors: [ + { + messageId: 'noAwaitSyncQuery', + line: 2, + column: 38, + }, + ], + } as const) + ), + + // sync queries in screen with await operator inside assert are not valid + ...SYNC_QUERIES_COMBINATIONS.map( + (query) => + ({ + code: `async () => { + expect(await screen.${query}('foo')).toBeEnabled() + } + `, + errors: [ + { + messageId: 'noAwaitSyncQuery', + line: 2, + column: 29, + }, + ], + } as const) + ), + + // sync query awaited and related to testing library module + // with custom module setting is not valid + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { screen } from '@testing-library/react' + () => { + const element = await screen.getByRole('button') + } + `, + errors: [{ messageId: 'noAwaitSyncQuery', line: 4, column: 38 }], + }, + // sync query awaited and related to custom module is not valid + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { screen } from 'test-utils' + () => { + const element = await screen.getByRole('button') + } + `, + errors: [{ messageId: 'noAwaitSyncQuery', line: 4, column: 38 }], + }, ], }); diff --git a/tests/lib/rules/no-container.test.ts b/tests/lib/rules/no-container.test.ts new file mode 100644 index 00000000..0c15d5f4 --- /dev/null +++ b/tests/lib/rules/no-container.test.ts @@ -0,0 +1,217 @@ +import { createRuleTester } from '../test-utils'; +import rule, { RULE_NAME } from '../../../lib/rules/no-container'; + +const ruleTester = createRuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: ` + render(); + screen.getByRole('button', {name: /click me/i}); + `, + }, + { + code: ` + const { container } = render(); + expect(container.firstChild).toBeDefined(); + `, + }, + { + code: ` + const { container: alias } = render(); + expect(alias.firstChild).toBeDefined(); + `, + }, + { + code: ` + function getExampleDOM() { + const container = document.createElement('div'); + container.innerHTML = \` + + + + \`; + const button = container.querySelector('button'); + + button.addEventListener('click', () => console.log('clicked')); + return container; + } + + const exampleDOM = getExampleDOM(); + screen.getByText(exampleDOM, 'Print Username').click(); + `, + }, + { + code: ` + const { container: { firstChild } } = render(); + expect(firstChild).toBeDefined(); + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render as renamed } from '@testing-library/react' + import { render } from 'somewhere-else' + const { container } = render(); + const button = container.querySelector('.btn-primary'); + `, + }, + { + settings: { + 'testing-library/custom-renders': ['customRender', 'renderWithRedux'], + }, + code: ` + import { otherRender } from 'somewhere-else' + const { container } = otherRender(); + const button = container.querySelector('.btn-primary'); + `, + }, + ], + invalid: [ + { + code: ` + const { container } = render(); + const button = container.querySelector('.btn-primary'); + `, + errors: [ + { + line: 3, + column: 24, + messageId: 'noContainer', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render } from 'test-utils' + const { container } = render(); + const button = container.querySelector('.btn-primary'); + `, + errors: [ + { + line: 4, + column: 24, + messageId: 'noContainer', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render as testingRender } from '@testing-library/react' + const { container: renamed } = testingRender(); + const button = renamed.querySelector('.btn-primary'); + `, + errors: [ + { + line: 4, + column: 24, + messageId: 'noContainer', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render } from '@testing-library/react' + + const setup = () => render() + + const { container } = setup() + const button = container.querySelector('.btn-primary'); + `, + errors: [ + { + line: 7, + column: 24, + messageId: 'noContainer', + }, + ], + }, + { + code: ` + const { container } = render(); + container.querySelector(); + `, + errors: [ + { + line: 3, + column: 9, + messageId: 'noContainer', + }, + ], + }, + { + code: ` + const { container: alias } = render(); + alias.querySelector(); + `, + errors: [ + { + line: 3, + column: 9, + messageId: 'noContainer', + }, + ], + }, + { + code: ` + const view = render(); + const button = view.container.querySelector('.btn-primary'); + `, + errors: [ + { + line: 3, + column: 29, + messageId: 'noContainer', + }, + ], + }, + { + code: ` + const { container: { querySelector } } = render(); + querySelector('foo'); + `, + errors: [ + { + line: 3, + column: 9, + messageId: 'noContainer', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render } from '@testing-library/react' + const { container: { querySelector } } = render(); + querySelector('foo'); + `, + errors: [ + { + line: 4, + column: 9, + messageId: 'noContainer', + }, + ], + }, + { + settings: { + 'testing-library/custom-renders': ['customRender', 'renderWithRedux'], + }, + code: ` + const { container } = renderWithRedux(); + container.querySelector(); + `, + errors: [ + { + line: 3, + column: 9, + messageId: 'noContainer', + }, + ], + }, + ], +}); diff --git a/tests/lib/rules/no-debug.test.ts b/tests/lib/rules/no-debug.test.ts index ad175bac..fda5faab 100644 --- a/tests/lib/rules/no-debug.test.ts +++ b/tests/lib/rules/no-debug.test.ts @@ -1,18 +1,23 @@ import { createRuleTester } from '../test-utils'; import rule, { RULE_NAME } from '../../../lib/rules/no-debug'; -const ruleTester = createRuleTester({ - ecmaFeatures: { - jsx: true, - }, -}); +const ruleTester = createRuleTester(); ruleTester.run(RULE_NAME, rule, { valid: [ { + settings: { 'testing-library/utils-module': 'test-utils' }, code: `debug()`, }, { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { screen } from 'somewhere-else' + screen.debug() + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, code: `() => { const somethingElse = {} const { debug } = foo() @@ -20,6 +25,7 @@ ruleTester.run(RULE_NAME, rule, { }`, }, { + settings: { 'testing-library/utils-module': 'test-utils' }, code: ` let foo const debug = require('debug') @@ -45,6 +51,7 @@ ruleTester.run(RULE_NAME, rule, { `, }, { + settings: { 'testing-library/utils-module': 'test-utils' }, code: `screen.debug()`, }, { @@ -68,6 +75,7 @@ ruleTester.run(RULE_NAME, rule, { `, }, { + settings: { 'testing-library/utils-module': 'test-utils' }, code: ` import * as foo from '@somewhere/else'; foo.debug(); @@ -77,12 +85,14 @@ ruleTester.run(RULE_NAME, rule, { code: `import { queries } from '@testing-library/dom'`, }, { + settings: { 'testing-library/utils-module': 'test-utils' }, code: ` const { screen } = require('something-else') screen.debug() `, }, { + settings: { 'testing-library/utils-module': 'test-utils' }, code: ` import { screen } from 'something-else' screen.debug() @@ -95,9 +105,74 @@ ruleTester.run(RULE_NAME, rule, { } `, }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { debug as testingDebug } from 'test-utils' + import { debug } from 'somewhere-else' + + debug() + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render as testingRender } from '@testing-library/react' + import { render } from 'somewhere-else' + + const { debug } = render(element) + + somethingElse() + debug() + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render as testingRender } from '@testing-library/react' + import { render } from 'somewhere-else' + + const { debug } = render(element) + const { debug: testingDebug } = testingRender(element) + + somethingElse() + debug() + `, + }, + + `// cover edge case for https://github.com/testing-library/eslint-plugin-testing-library/issues/306 + thing.method.lastCall.args[0](); + `, ], invalid: [ + { + code: `debug()`, + errors: [{ line: 1, column: 1, messageId: 'noDebug' }], + }, + { + code: ` + import { screen } from 'aggressive-reporting' + screen.debug() + `, + errors: [{ line: 3, column: 14, messageId: 'noDebug' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { screen } from 'test-utils' + screen.debug() + `, + errors: [{ line: 3, column: 14, messageId: 'noDebug' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { debug as testingDebug } from 'test-utils' + testingDebug() + `, + errors: [{ line: 3, column: 7, messageId: 'noDebug' }], + }, { code: ` const { debug } = render() @@ -105,33 +180,70 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [ { + line: 3, + column: 9, messageId: 'noDebug', }, ], }, { + settings: { + 'testing-library/custom-renders': ['customRender', 'renderWithRedux'], + }, code: ` const { debug } = renderWithRedux() debug() `, - options: [ + errors: [ { - renderFunctions: ['renderWithRedux'], + line: 3, + column: 9, + messageId: 'noDebug', }, ], + }, + { + code: ` + const utils = render() + utils.debug() + `, errors: [ { + line: 3, + column: 15, messageId: 'noDebug', }, ], }, { + settings: { 'testing-library/utils-module': 'test-utils' }, code: ` + import { render } from 'test-utils' + + const setup = () => render() + + const utils = setup() + utils.debug() + `, + errors: [ + { + line: 7, + column: 15, + messageId: 'noDebug', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// aggressive reporting disabled + import { render } from 'test-utils' const utils = render() utils.debug() `, errors: [ { + line: 4, + column: 15, messageId: 'noDebug', }, ], @@ -145,9 +257,35 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [ { + line: 3, + column: 15, + messageId: 'noDebug', + }, + { + line: 5, + column: 15, + messageId: 'noDebug', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// aggressive reporting disabled + import { render } from 'test-utils' + const utils = render() + utils.debug() + utils.foo() + utils.debug() + `, + errors: [ + { + line: 4, + column: 15, messageId: 'noDebug', }, { + line: 6, + column: 15, messageId: 'noDebug', }, ], @@ -162,6 +300,26 @@ ruleTester.run(RULE_NAME, rule, { })`, errors: [ { + line: 5, + column: 11, + messageId: 'noDebug', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// aggressive reporting disabled + import { render } from 'test-utils' + describe(() => { + test(async () => { + const { debug } = await render("foo") + debug() + }) + })`, + errors: [ + { + line: 6, + column: 11, messageId: 'noDebug', }, ], @@ -176,6 +334,26 @@ ruleTester.run(RULE_NAME, rule, { })`, errors: [ { + line: 5, + column: 17, + messageId: 'noDebug', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// aggressive reporting disabled + import { render } from 'test-utils' + describe(() => { + test(async () => { + const utils = await render("foo") + utils.debug() + }) + })`, + errors: [ + { + line: 6, + column: 17, messageId: 'noDebug', }, ], @@ -187,6 +365,22 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [ { + line: 3, + column: 16, + messageId: 'noDebug', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// aggressive reporting disabled + const { screen } = require('@testing-library/dom') + screen.debug() + `, + errors: [ + { + line: 3, + column: 16, messageId: 'noDebug', }, ], @@ -198,6 +392,22 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [ { + line: 3, + column: 16, + messageId: 'noDebug', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// aggressive reporting disabled + import { screen } from '@testing-library/dom' + screen.debug() + `, + errors: [ + { + line: 3, + column: 16, messageId: 'noDebug', }, ], @@ -210,11 +420,28 @@ ruleTester.run(RULE_NAME, rule, { `, errors: [ { + line: 3, + column: 16, + messageId: 'noDebug', + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// aggressive reporting disabled + import { screen, render } from '@testing-library/dom' + screen.debug() + `, + errors: [ + { + line: 3, + column: 16, messageId: 'noDebug', }, ], }, { + settings: { 'testing-library/utils-module': 'test-utils' }, code: ` import * as dtl from '@testing-library/dom'; dtl.debug(); @@ -227,5 +454,67 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + code: ` + import { render } from 'aggressive-reporting' + + const { debug } = render(element) + + somethingElse() + debug() + `, + errors: [{ line: 7, column: 7, messageId: 'noDebug' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render } from '@testing-library/react' + + const { debug } = render(element) + + somethingElse() + debug() + `, + errors: [{ line: 7, column: 7, messageId: 'noDebug' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render } from 'test-utils' + + const { debug: renamed } = render(element) + + somethingElse() + renamed() + `, + errors: [{ line: 7, column: 7, messageId: 'noDebug' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render } from '@testing-library/react' + + const utils = render(element) + + somethingElse() + utils.debug() + `, + errors: [{ line: 7, column: 13, messageId: 'noDebug' }], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + 'testing-library/custom-renders': ['testingRender'], + }, + code: `// aggressive reporting disabled, custom render set + import { testingRender } from 'test-utils' + + const { debug: renamedDebug } = testingRender(element) + + somethingElse() + renamedDebug() + `, + errors: [{ line: 7, column: 7, messageId: 'noDebug' }], + }, ], }); diff --git a/tests/lib/rules/no-dom-import.test.ts b/tests/lib/rules/no-dom-import.test.ts index 429c8789..2855cd74 100644 --- a/tests/lib/rules/no-dom-import.test.ts +++ b/tests/lib/rules/no-dom-import.test.ts @@ -5,22 +5,26 @@ const ruleTester = createRuleTester(); ruleTester.run(RULE_NAME, rule, { valid: [ - { code: 'import { foo } from "foo"' }, - { code: 'import "foo"' }, - { code: 'import { fireEvent } from "react-testing-library"' }, - { code: 'import * as testing from "react-testing-library"' }, - { code: 'import { fireEvent } from "@testing-library/react"' }, - { code: 'import * as testing from "@testing-library/react"' }, - { code: 'import "react-testing-library"' }, - { code: 'import "@testing-library/react"' }, - { code: 'const { foo } = require("foo")' }, - { code: 'require("foo")' }, - { code: 'require("")' }, - { code: 'require()' }, - { code: 'const { fireEvent } = require("react-testing-library")' }, - { code: 'const { fireEvent } = require("@testing-library/react")' }, - { code: 'require("react-testing-library")' }, - { code: 'require("@testing-library/react")' }, + 'import { foo } from "foo"', + 'import "foo"', + 'import { fireEvent } from "react-testing-library"', + 'import * as testing from "react-testing-library"', + 'import { fireEvent } from "@testing-library/react"', + 'import * as testing from "@testing-library/react"', + 'import "react-testing-library"', + 'import "@testing-library/react"', + 'const { foo } = require("foo")', + 'require("foo")', + 'require("")', + 'require()', + 'const { fireEvent } = require("react-testing-library")', + 'const { fireEvent } = require("@testing-library/react")', + 'require("react-testing-library")', + 'require("@testing-library/react")', + { + code: 'import { fireEvent } from "test-utils"', + settings: { 'testing-library/utils-module': 'test-utils' }, + }, ], invalid: [ { @@ -32,6 +36,23 @@ ruleTester.run(RULE_NAME, rule, { ], output: 'import { fireEvent } from "dom-testing-library"', }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: dom-testing-library imported with custom module setting + import { fireEvent } from "dom-testing-library"`, + errors: [ + { + line: 3, + messageId: 'noDomImport', + }, + ], + output: ` + // case: dom-testing-library imported with custom module setting + import { fireEvent } from "dom-testing-library"`, + }, { code: 'import { fireEvent } from "dom-testing-library"', options: ['react'], @@ -67,6 +88,20 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: dom-testing-library wildcard imported with custom module setting + import * as testing from "dom-testing-library"`, + errors: [ + { + line: 3, + messageId: 'noDomImport', + }, + ], + }, { code: 'import { fireEvent } from "@testing-library/dom"', errors: [ @@ -75,6 +110,20 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: @testing-library/dom imported with custom module setting + import { fireEvent } from "@testing-library/dom"`, + errors: [ + { + line: 3, + messageId: 'noDomImport', + }, + ], + }, { code: 'import * as testing from "@testing-library/dom"', errors: [ @@ -107,6 +156,20 @@ ruleTester.run(RULE_NAME, rule, { }, ], }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: dom-testing-library required with custom module setting + const { fireEvent } = require("dom-testing-library")`, + errors: [ + { + line: 3, + messageId: 'noDomImport', + }, + ], + }, { code: 'const { fireEvent } = require("@testing-library/dom")', errors: [ @@ -128,6 +191,26 @@ ruleTester.run(RULE_NAME, rule, { ], output: 'const { fireEvent } = require("@testing-library/vue")', }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: @testing-library/dom required with custom module setting + const { fireEvent } = require("@testing-library/dom")`, + options: ['vue'], + errors: [ + { + messageId: 'noDomImportFramework', + data: { + module: '@testing-library/vue', + }, + }, + ], + output: ` + // case: @testing-library/dom required with custom module setting + const { fireEvent } = require("@testing-library/vue")`, + }, { code: 'require("dom-testing-library")', errors: [ diff --git a/tests/lib/rules/no-manual-cleanup.test.ts b/tests/lib/rules/no-manual-cleanup.test.ts index bd02fb42..d89eb3b2 100644 --- a/tests/lib/rules/no-manual-cleanup.test.ts +++ b/tests/lib/rules/no-manual-cleanup.test.ts @@ -13,22 +13,29 @@ const ALL_TESTING_LIBRARIES_WITH_CLEANUP = [ ruleTester.run(RULE_NAME, rule, { valid: [ - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ - code: `import { render } from "${lib}"`, - })), { - code: `import { cleanup } from "any-other-library"`, + code: `import "@testing-library/react"`, }, - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ + { + code: `import { cleanup } from "test-utils"`, + }, + { + // Angular Testing Library doesn't have `cleanup` util + code: `import { cleanup } from "@testing-library/angular"`, + }, + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map((lib) => ({ + code: `import { render } from "${lib}"`, + })), + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map((lib) => ({ code: `import utils from "${lib}"`, })), - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map((lib) => ({ code: ` import utils from "${lib}" utils.render() `, })), - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map((lib) => ({ code: `const { render, within } = require("${lib}")`, })), { @@ -49,97 +56,184 @@ ruleTester.run(RULE_NAME, rule, { }, ], invalid: [ - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ - code: `import { render, cleanup } from "${lib}"`, - errors: [ - { - line: 1, - column: 18, // error points to `cleanup` - messageId: 'noManualCleanup', - }, - ], - })), - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ - code: `import { cleanup as myCustomCleanup } from "${lib}"`, - errors: [ - { - line: 1, - column: 10, // error points to `cleanup` - messageId: 'noManualCleanup', - }, - ], - })), - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ - code: `import utils, { cleanup } from "${lib}"`, - errors: [ - { - line: 1, - column: 17, // error points to `cleanup` - messageId: 'noManualCleanup', - }, - ], - })), - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map( + (lib) => + ({ + code: `import { render, cleanup } from "${lib}"`, + errors: [ + { + line: 1, + column: 18, // error points to `cleanup` + messageId: 'noManualCleanup', + }, + ], + } as const) + ), + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map( + (lib) => + ({ + // official testing-library packages should be reported with custom module setting + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { cleanup, render } from "${lib}"`, + errors: [ + { + line: 1, + column: 10, // error points to `cleanup` + messageId: 'noManualCleanup', + }, + ], + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` + import { render, cleanup } from 'test-utils' + `, + errors: [{ line: 2, column: 26, messageId: 'noManualCleanup' }], + }, + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map( + (lib) => + ({ + code: `import { cleanup as myCustomCleanup } from "${lib}"`, + errors: [ + { + line: 1, + column: 10, // error points to `cleanup` + messageId: 'noManualCleanup', + }, + ], + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { cleanup as myCustomCleanup } from 'test-utils' + `, + errors: [{ line: 2, column: 18, messageId: 'noManualCleanup' }], + }, + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map( + (lib) => + ({ + code: `import utils, { cleanup } from "${lib}"`, + errors: [ + { + line: 1, + column: 17, // error points to `cleanup` + messageId: 'noManualCleanup', + }, + ], + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import utils, { cleanup } from 'test-utils' + `, + errors: [{ line: 2, column: 25, messageId: 'noManualCleanup' }], + }, + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map( + (lib) => + ({ + code: ` import utils from "${lib}" afterEach(() => utils.cleanup()) `, - errors: [ - { - line: 3, - column: 31, - messageId: 'noManualCleanup', - }, - ], - })), - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ + errors: [ + { + line: 3, + column: 31, + messageId: 'noManualCleanup', + }, + ], + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` + import utils from 'test-utils' + afterEach(() => utils.cleanup()) + `, + errors: [{ line: 3, column: 31, messageId: 'noManualCleanup' }], + }, + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map( + (lib) => + ({ + code: ` import utils from "${lib}" afterEach(utils.cleanup) `, - errors: [ - { - line: 3, - column: 25, - messageId: 'noManualCleanup', - }, - ], - })), - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ - code: `const { cleanup } = require("${lib}")`, - errors: [ - { - line: 1, - column: 9, // error points to `cleanup` - messageId: 'noManualCleanup', - }, - ], - })), - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ + errors: [ + { + line: 3, + column: 25, + messageId: 'noManualCleanup', + }, + ], + } as const) + ), + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map( + (lib) => + ({ + code: `const { cleanup } = require("${lib}")`, + errors: [ + { + line: 1, + column: 9, // error points to `cleanup` + messageId: 'noManualCleanup', + }, + ], + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` + const { render, cleanup } = require('test-utils') + `, + errors: [{ line: 2, column: 25, messageId: 'noManualCleanup' }], + }, + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map( + (lib) => + ({ + code: ` const utils = require("${lib}") afterEach(() => utils.cleanup()) `, - errors: [ - { - line: 3, - column: 31, - messageId: 'noManualCleanup', - }, - ], - })), - ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map(lib => ({ - code: ` + errors: [ + { + line: 3, + column: 31, + messageId: 'noManualCleanup', + }, + ], + } as const) + ), + ...ALL_TESTING_LIBRARIES_WITH_CLEANUP.map( + (lib) => + ({ + code: ` const utils = require("${lib}") afterEach(utils.cleanup) `, - errors: [ - { - line: 3, - column: 25, - messageId: 'noManualCleanup', - }, - ], - })), + errors: [ + { + line: 3, + column: 25, + messageId: 'noManualCleanup', + }, + ], + } as const) + ), ], }); diff --git a/tests/lib/rules/no-node-access.test.ts b/tests/lib/rules/no-node-access.test.ts new file mode 100644 index 00000000..4b8afb36 --- /dev/null +++ b/tests/lib/rules/no-node-access.test.ts @@ -0,0 +1,262 @@ +import { createRuleTester } from '../test-utils'; +import rule, { RULE_NAME } from '../../../lib/rules/no-node-access'; + +const ruleTester = createRuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: ` + import { screen } from '@testing-library/react'; + + const buttonText = screen.getByText('submit'); + `, + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const { getByText } = screen + const firstChild = getByText('submit'); + expect(firstChild).toBeInTheDocument() + `, + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const firstChild = screen.getByText('submit'); + expect(firstChild).toBeInTheDocument() + `, + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const { getByText } = screen; + const button = getByRole('button'); + expect(button).toHaveTextContent('submit'); + `, + }, + { + code: ` + import { render, within } from '@testing-library/react'; + + const { getByLabelText } = render(); + const signinModal = getByLabelText('Sign In'); + within(signinModal).getByPlaceholderText('Username'); + `, + }, + { + code: ` + // case: importing custom module + const closestButton = document.getElementById('submit-btn').closest('button'); + expect(closestButton).toBeInTheDocument(); + `, + settings: { + 'testing-library/utils-module': 'test-utils', + }, + }, + ], + invalid: [ + { + code: ` + // case: without importing TL (aggressive reporting) + const closestButton = document.getElementById('submit-btn') + expect(closestButton).toBeInTheDocument(); + `, + errors: [{ messageId: 'noNodeAccess', line: 3 }], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const button = document.getElementById('submit-btn').closest('button'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + document.getElementById('submit-btn'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + screen.getByText('submit').closest('button'); + `, + errors: [ + { + // error points to `closest` + line: 4, + column: 36, + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + expect(screen.getByText('submit').closest('button').textContent).toBe('Submit'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { render } from '@testing-library/react'; + + const { getByText } = render() + getByText('submit').closest('button'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const buttons = screen.getAllByRole('button'); + const childA = buttons[1].firstChild; + const button = buttons[2]; + button.lastChild + `, + errors: [ + { + // error points to `firstChild` + line: 5, + column: 35, + messageId: 'noNodeAccess', + }, + { + // error points to `lastChild` + line: 7, + column: 16, + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + const buttonText = screen.getByText('submit'); + const button = buttonText.closest('button'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { render } from '@testing-library/react'; + + const { getByText } = render() + const buttonText = getByText('submit'); + const button = buttonText.closest('button'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { render } from '@testing-library/react'; + + const { getByText } = render() + const button = getByText('submit').closest('button'); + `, + errors: [ + { + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + function getExampleDOM() { + const container = document.createElement('div'); + container.innerHTML = \` + + + + + + + + \`; + return container; + }; + const exampleDOM = getExampleDOM(); + const buttons = screen.getAllByRole(exampleDOM, 'button'); + const buttonText = buttons[1].firstChild; + `, + errors: [ + { + // error points to `firstChild` + line: 19, + column: 39, + messageId: 'noNodeAccess', + }, + ], + }, + { + code: ` + import { screen } from '@testing-library/react'; + + function getExampleDOM() { + const container = document.createElement('div'); + container.innerHTML = \` + + + + + + + + \`; + return container; + }; + const exampleDOM = getExampleDOM(); + const submitButton = screen.getByText(exampleDOM, 'Submit'); + const previousButton = submitButton.previousSibling; + `, + errors: [ + { + // error points to `previousSibling` + line: 19, + column: 45, + messageId: 'noNodeAccess', + }, + ], + }, + ], +}); diff --git a/tests/lib/rules/no-promise-in-fire-event.test.ts b/tests/lib/rules/no-promise-in-fire-event.test.ts new file mode 100644 index 00000000..66023be2 --- /dev/null +++ b/tests/lib/rules/no-promise-in-fire-event.test.ts @@ -0,0 +1,175 @@ +import { createRuleTester } from '../test-utils'; +import rule, { RULE_NAME } from '../../../lib/rules/no-promise-in-fire-event'; + +const ruleTester = createRuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: ` + import {fireEvent} from '@testing-library/foo'; + + fireEvent.click(screen.getByRole('button')) + `, + }, + { + code: ` + import {fireEvent} from '@testing-library/foo'; + + fireEvent.click(queryByRole('button'))`, + }, + { + code: ` + import {fireEvent} from '@testing-library/foo'; + + fireEvent.click(someRef)`, + }, + { + code: ` + import {fireEvent} from '@testing-library/foo'; + + fireEvent.click(await screen.findByRole('button')) + `, + }, + { + code: ` + import {fireEvent} from '@testing-library/foo' + + const elementPromise = screen.findByRole('button') + const button = await elementPromise + fireEvent.click(button)`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `// invalid usage but aggressive reporting opted-out + import { fireEvent } from 'somewhere-else' + fireEvent.click(findByText('submit')) + `, + }, + `// edge case for coverage: + // valid use case without call expression + // so there is no innermost function scope found + test('edge case for no innermost function scope', () => { + const click = fireEvent.click + }) + `, + `// edge case for coverage: + // new expression of something else than Promise + fireEvent.click(new SomeElement()) + `, + ], + invalid: [ + { + // aggressive reporting opted-in + code: `fireEvent.click(findByText('submit'))`, + errors: [ + { + messageId: 'noPromiseInFireEvent', + line: 1, + column: 17, + endColumn: 37, + }, + ], + }, + { + // aggressive reporting opted-in + code: `fireEvent.click(Promise())`, + errors: [ + { + messageId: 'noPromiseInFireEvent', + line: 1, + column: 17, + endColumn: 26, + }, + ], + }, + { + code: ` + import {fireEvent} from '@testing-library/foo'; + + const promise = new Promise(); + fireEvent.click(promise)`, + errors: [ + { + messageId: 'noPromiseInFireEvent', + line: 5, + column: 25, + endColumn: 32, + }, + ], + }, + { + code: ` + import {fireEvent} from '@testing-library/foo' + + const elementPromise = screen.findByRole('button') + fireEvent.click(elementPromise)`, + errors: [ + { + messageId: 'noPromiseInFireEvent', + line: 5, + column: 25, + endColumn: 39, + }, + ], + }, + { + code: ` + import {fireEvent} from '@testing-library/foo'; + + fireEvent.click(screen.findByRole('button'))`, + errors: [ + { + messageId: 'noPromiseInFireEvent', + line: 4, + column: 25, + endColumn: 52, + }, + ], + }, + { + code: ` + import {fireEvent} from '@testing-library/foo'; + + fireEvent.click(findByText('submit'))`, + errors: [ + { + messageId: 'noPromiseInFireEvent', + line: 4, + column: 25, + endColumn: 45, + }, + ], + }, + { + code: ` + import {fireEvent} from '@testing-library/foo'; + + fireEvent.click(Promise('foo'))`, + errors: [ + { + messageId: 'noPromiseInFireEvent', + line: 4, + column: 25, + endColumn: 39, + }, + ], + }, + { + code: ` + import {fireEvent} from '@testing-library/foo'; + + fireEvent.click(new Promise('foo'))`, + errors: [ + { + messageId: 'noPromiseInFireEvent', + line: 4, + column: 25, + endColumn: 43, + }, + ], + }, + ], +}); diff --git a/tests/lib/rules/no-render-in-setup.test.ts b/tests/lib/rules/no-render-in-setup.test.ts index 1f4a529d..8633e280 100644 --- a/tests/lib/rules/no-render-in-setup.test.ts +++ b/tests/lib/rules/no-render-in-setup.test.ts @@ -2,39 +2,48 @@ import { createRuleTester } from '../test-utils'; import { TESTING_FRAMEWORK_SETUP_HOOKS } from '../../../lib/utils'; import rule, { RULE_NAME } from '../../../lib/rules/no-render-in-setup'; -const ruleTester = createRuleTester({ - ecmaFeatures: { - jsx: true, - }, -}); +const ruleTester = createRuleTester(); ruleTester.run(RULE_NAME, rule, { valid: [ { code: ` import { render } from '@testing-library/foo'; + + beforeAll(() => { + doOtherStuff(); + }); + + beforeEach(() => { + doSomethingElse(); + }); + it('Test', () => { render() }) `, }, // test config options - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(setupHook => ({ + { code: ` - import { renderWithRedux } from '../test-utils'; - ${setupHook}(() => { - renderWithRedux() - }) - `, - options: [ - { - allowTestingFrameworkSetupHook: setupHook, - renderFunctions: ['renderWithRedux'], - }, - ], - })), - // test usage of a non-Testing Library render fn - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(setupHook => ({ + import { render } from '@testing-library/foo'; + beforeAll(() => { + render(); + }); + `, + options: [{ allowTestingFrameworkSetupHook: 'beforeAll' }], + }, + { + code: ` + import { render } from '@testing-library/foo'; + beforeEach(() => { + render(); + }); + `, + options: [{ allowTestingFrameworkSetupHook: 'beforeEach' }], + }, + ...TESTING_FRAMEWORK_SETUP_HOOKS.map((setupHook) => ({ + settings: { 'testing-library/utils-module': 'test-utils' }, code: ` import { render } from 'imNoTestingLibrary'; ${setupHook}(() => { @@ -42,16 +51,20 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(allowedSetupHook => { + ...TESTING_FRAMEWORK_SETUP_HOOKS.map((allowedSetupHook) => { const [disallowedHook] = TESTING_FRAMEWORK_SETUP_HOOKS.filter( - setupHook => setupHook !== allowedSetupHook + (setupHook) => setupHook !== allowedSetupHook ); return { + settings: { + 'testing-library/utils-module': 'test-utils', + 'testing-library/custom-renders': ['show', 'renderWithRedux'], + }, code: ` import utils from 'imNoTestingLibrary'; - import { renderWithRedux } from '../test-utils'; + import { show } from '../test-utils'; ${allowedSetupHook}(() => { - renderWithRedux() + show() }) ${disallowedHook}(() => { utils.render() @@ -60,12 +73,12 @@ ruleTester.run(RULE_NAME, rule, { options: [ { allowTestingFrameworkSetupHook: allowedSetupHook, - renderFunctions: ['renderWithRedux'], }, ], }; }), - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(setupHook => ({ + ...TESTING_FRAMEWORK_SETUP_HOOKS.map((setupHook) => ({ + settings: { 'testing-library/utils-module': 'test-utils' }, code: ` const { render } = require('imNoTestingLibrary') @@ -82,71 +95,90 @@ ruleTester.run(RULE_NAME, rule, { ], invalid: [ - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(setupHook => ({ - code: ` + ...TESTING_FRAMEWORK_SETUP_HOOKS.map( + (setupHook) => + ({ + code: ` import { render } from '@testing-library/foo'; ${setupHook}(() => { render() }) `, - errors: [ - { - messageId: 'noRenderInSetup', - }, - ], - })), - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(setupHook => ({ - code: ` + errors: [ + { + line: 4, + column: 11, + messageId: 'noRenderInSetup', + }, + ], + } as const) + ), + ...TESTING_FRAMEWORK_SETUP_HOOKS.map( + (setupHook) => + ({ + code: ` import { render } from '@testing-library/foo'; ${setupHook}(function() { render() }) `, - errors: [ - { - messageId: 'noRenderInSetup', - }, - ], - })), + errors: [ + { + line: 4, + column: 11, + messageId: 'noRenderInSetup', + }, + ], + } as const) + ), // custom render function - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(setupHook => ({ - code: ` - import { renderWithRedux } from '../test-utils'; + ...TESTING_FRAMEWORK_SETUP_HOOKS.map( + (setupHook) => + ({ + settings: { + 'testing-library/utils-module': 'test-utils', + 'testing-library/custom-renders': ['show', 'renderWithRedux'], + }, + code: ` + import { show } from '../test-utils'; + ${setupHook}(() => { - renderWithRedux() + show() }) `, - options: [ - { - renderFunctions: ['renderWithRedux'], - }, - ], - errors: [ - { - messageId: 'noRenderInSetup', - }, - ], - })), - // call render within a wrapper function - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(setupHook => ({ - code: ` + errors: [ + { + line: 5, + column: 11, + messageId: 'noRenderInSetup', + }, + ], + } as const) + ), + ...TESTING_FRAMEWORK_SETUP_HOOKS.map( + (setupHook) => + ({ + code: `// call render within a wrapper function import { render } from '@testing-library/foo'; - ${setupHook}(() => { - const wrapper = () => { - render() - } - wrapper() - }) + + const wrapper = () => render() + + ${setupHook}(() => { + wrapper() + }) `, - errors: [ - { - messageId: 'noRenderInSetup', - }, - ], - })), - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(allowedSetupHook => { + errors: [ + { + line: 7, + column: 9, + messageId: 'noRenderInSetup', + }, + ], + } as const) + ), + ...TESTING_FRAMEWORK_SETUP_HOOKS.map((allowedSetupHook) => { const [disallowedHook] = TESTING_FRAMEWORK_SETUP_HOOKS.filter( - setupHook => setupHook !== allowedSetupHook + (setupHook) => setupHook !== allowedSetupHook ); return { code: ` @@ -162,26 +194,36 @@ ruleTester.run(RULE_NAME, rule, { ], errors: [ { + line: 4, + column: 13, messageId: 'noRenderInSetup', }, ], - }; + } as const; }), - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(setupHook => ({ - code: ` + ...TESTING_FRAMEWORK_SETUP_HOOKS.map( + (setupHook) => + ({ + code: ` import * as testingLibrary from '@testing-library/foo'; ${setupHook}(() => { testingLibrary.render() }) `, - errors: [ - { - messageId: 'noRenderInSetup', - }, - ], - })), - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(setupHook => ({ - code: ` + errors: [ + { + line: 4, + column: 26, + messageId: 'noRenderInSetup', + }, + ], + } as const) + ), + ...TESTING_FRAMEWORK_SETUP_HOOKS.map( + (setupHook) => + ({ + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` import { render } from 'imNoTestingLibrary'; import * as testUtils from '../test-utils'; ${setupHook}(() => { @@ -191,30 +233,33 @@ ruleTester.run(RULE_NAME, rule, { render() }) `, - options: [ - { - renderFunctions: ['renderWithRedux'], - }, - ], - errors: [ - { - messageId: 'noRenderInSetup', - }, - ], - })), - ...TESTING_FRAMEWORK_SETUP_HOOKS.map(setupHook => ({ - code: ` + errors: [ + { + line: 5, + column: 21, + messageId: 'noRenderInSetup', + }, + ], + } as const) + ), + ...TESTING_FRAMEWORK_SETUP_HOOKS.map( + (setupHook) => + ({ + code: ` const { render } = require('@testing-library/foo') ${setupHook}(() => { render() }) `, - errors: [ - { - messageId: 'noRenderInSetup', - }, - ], - })), + errors: [ + { + line: 5, + column: 11, + messageId: 'noRenderInSetup', + }, + ], + } as const) + ), ], }); diff --git a/tests/lib/rules/no-wait-for-empty-callback.test.ts b/tests/lib/rules/no-wait-for-empty-callback.test.ts index a6edaf2f..87bbd3fc 100644 --- a/tests/lib/rules/no-wait-for-empty-callback.test.ts +++ b/tests/lib/rules/no-wait-for-empty-callback.test.ts @@ -7,12 +7,12 @@ const ALL_WAIT_METHODS = ['waitFor', 'waitForElementToBeRemoved']; ruleTester.run(RULE_NAME, rule, { valid: [ - ...ALL_WAIT_METHODS.map(m => ({ + ...ALL_WAIT_METHODS.map((m) => ({ code: `${m}(() => { screen.getByText(/submit/i) })`, })), - ...ALL_WAIT_METHODS.map(m => ({ + ...ALL_WAIT_METHODS.map((m) => ({ code: `${m}(function() { screen.getByText(/submit/i) })`, @@ -29,72 +29,186 @@ ruleTester.run(RULE_NAME, rule, { { code: `wait(() => {})`, }, + { + code: `wait(noop)`, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor } from 'somewhere-else' + waitFor(() => {}) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor as renamedWaitFor } from '@testing-library/react' + import { waitFor } from 'somewhere-else' + waitFor(() => {}) + `, + }, ], invalid: [ - ...ALL_WAIT_METHODS.map(m => ({ - code: `${m}(() => {})`, - errors: [ - { - messageId: 'noWaitForEmptyCallback', - }, - ], - })), - ...ALL_WAIT_METHODS.map(m => ({ - code: `${m}((a, b) => {})`, - errors: [ - { - messageId: 'noWaitForEmptyCallback', - }, - ], - })), - ...ALL_WAIT_METHODS.map(m => ({ - code: `${m}(() => { /* I'm empty anyway */ })`, - errors: [ - { - messageId: 'noWaitForEmptyCallback', - }, - ], - })), + ...ALL_WAIT_METHODS.map( + (m) => + ({ + code: `${m}(() => {})`, + errors: [ + { + line: 1, + column: 8 + m.length, + messageId: 'noWaitForEmptyCallback', + data: { + methodName: m, + }, + }, + ], + } as const) + ), + ...ALL_WAIT_METHODS.map( + (m) => + ({ + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { ${m} } from 'test-utils'; + ${m}(() => {}); + `, + errors: [ + { + line: 3, + column: 16 + m.length, + messageId: 'noWaitForEmptyCallback', + data: { + methodName: m, + }, + }, + ], + } as const) + ), + ...ALL_WAIT_METHODS.map( + (m) => + ({ + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { ${m} as renamedAsyncUtil } from 'test-utils'; + renamedAsyncUtil(() => {}); + `, + errors: [ + { + line: 3, + column: 32, + messageId: 'noWaitForEmptyCallback', + data: { + methodName: 'renamedAsyncUtil', + }, + }, + ], + } as const) + ), + ...ALL_WAIT_METHODS.map( + (m) => + ({ + code: `${m}((a, b) => {})`, + errors: [ + { + line: 1, + column: 12 + m.length, + messageId: 'noWaitForEmptyCallback', + data: { + methodName: m, + }, + }, + ], + } as const) + ), + ...ALL_WAIT_METHODS.map( + (m) => + ({ + code: `${m}(() => { /* I'm empty anyway */ })`, + errors: [ + { + line: 1, + column: 8 + m.length, + messageId: 'noWaitForEmptyCallback', + data: { + methodName: m, + }, + }, + ], + } as const) + ), - ...ALL_WAIT_METHODS.map(m => ({ - code: `${m}(function() { + ...ALL_WAIT_METHODS.map( + (m) => + ({ + code: `${m}(function() { })`, - errors: [ - { - messageId: 'noWaitForEmptyCallback', - }, - ], - })), - ...ALL_WAIT_METHODS.map(m => ({ - code: `${m}(function(a) { + errors: [ + { + line: 1, + column: 13 + m.length, + messageId: 'noWaitForEmptyCallback', + data: { + methodName: m, + }, + }, + ], + } as const) + ), + ...ALL_WAIT_METHODS.map( + (m) => + ({ + code: `${m}(function(a) { })`, - errors: [ - { - messageId: 'noWaitForEmptyCallback', - }, - ], - })), - ...ALL_WAIT_METHODS.map(m => ({ - code: `${m}(function() { + errors: [ + { + line: 1, + column: 14 + m.length, + messageId: 'noWaitForEmptyCallback', + data: { + methodName: m, + }, + }, + ], + } as const) + ), + ...ALL_WAIT_METHODS.map( + (m) => + ({ + code: `${m}(function() { // another empty callback })`, - errors: [ - { - messageId: 'noWaitForEmptyCallback', - }, - ], - })), + errors: [ + { + line: 1, + column: 13 + m.length, + messageId: 'noWaitForEmptyCallback', + data: { + methodName: m, + }, + }, + ], + } as const) + ), - ...ALL_WAIT_METHODS.map(m => ({ - code: `${m}(noop)`, - errors: [ - { - messageId: 'noWaitForEmptyCallback', - }, - ], - })), + ...ALL_WAIT_METHODS.map( + (m) => + ({ + code: `${m}(noop)`, + errors: [ + { + line: 1, + column: 2 + m.length, + messageId: 'noWaitForEmptyCallback', + data: { + methodName: m, + }, + }, + ], + } as const) + ), ], }); diff --git a/tests/lib/rules/no-wait-for-multiple-assertions.test.ts b/tests/lib/rules/no-wait-for-multiple-assertions.test.ts new file mode 100644 index 00000000..c470c02c --- /dev/null +++ b/tests/lib/rules/no-wait-for-multiple-assertions.test.ts @@ -0,0 +1,210 @@ +import { createRuleTester } from '../test-utils'; +import rule, { + RULE_NAME, +} from '../../../lib/rules/no-wait-for-multiple-assertions'; + +const ruleTester = createRuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: ` + await waitFor(() => expect(a).toEqual('a')) + `, + }, + { + code: ` + await waitFor(function() { + expect(a).toEqual('a') + }) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// Aggressive Reporting disabled - module imported not matching + import { waitFor } from 'somewhere-else' + await waitFor(() => { + expect(a).toEqual('a') + expect(b).toEqual('b') + }) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// Aggressive Reporting disabled - waitFor renamed + import { waitFor as renamedWaitFor } from '@testing-library/react' + import { waitFor } from 'somewhere-else' + await waitFor(() => { + expect(a).toEqual('a') + expect(b).toEqual('b') + }) + `, + }, + // this needs to be check by other rule + { + code: ` + await waitFor(() => { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + expect(b).toEqual('b') + }) + `, + }, + { + code: ` + await waitFor(function() { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + expect(b).toEqual('b') + }) + `, + }, + { + code: ` + await waitFor(() => { + console.log('testing-library') + expect(b).toEqual('b') + }) + `, + }, + { + code: ` + await waitFor(function() { + console.log('testing-library') + expect(b).toEqual('b') + }) + `, + }, + { + code: ` + await waitFor(() => {}) + `, + }, + { + code: ` + await waitFor(function() {}) + `, + }, + { + code: ` + await waitFor(() => { + // testing + }) + `, + }, + ], + invalid: [ + { + code: ` + await waitFor(() => { + expect(a).toEqual('a') + expect(b).toEqual('b') + }) + `, + errors: [ + { line: 4, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// Aggressive Reporting disabled + import { waitFor } from '@testing-library/react' + await waitFor(() => { + expect(a).toEqual('a') + expect(b).toEqual('b') + }) + `, + errors: [ + { line: 5, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// Aggressive Reporting disabled + import { waitFor as renamedWaitFor } from 'test-utils' + await renamedWaitFor(() => { + expect(a).toEqual('a') + expect(b).toEqual('b') + }) + `, + errors: [ + { line: 5, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + code: ` + await waitFor(() => { + expect(a).toEqual('a') + console.log('testing-library') + expect(b).toEqual('b') + }) + `, + errors: [ + { line: 5, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + code: ` + test('should whatever', async () => { + await waitFor(() => { + expect(a).toEqual('a') + console.log('testing-library') + expect(b).toEqual('b') + }) + }) + `, + errors: [ + { line: 6, column: 13, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + code: ` + await waitFor(async () => { + expect(a).toEqual('a') + await somethingAsync() + expect(b).toEqual('b') + }) + `, + errors: [ + { line: 5, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + code: ` + await waitFor(function() { + expect(a).toEqual('a') + expect(b).toEqual('b') + expect(c).toEqual('c') + expect(d).toEqual('d') + }) + `, + errors: [ + { line: 4, column: 11, messageId: 'noWaitForMultipleAssertion' }, + { line: 5, column: 11, messageId: 'noWaitForMultipleAssertion' }, + { line: 6, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + code: ` + await waitFor(function() { + expect(a).toEqual('a') + console.log('testing-library') + expect(b).toEqual('b') + }) + `, + errors: [ + { line: 5, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + { + code: ` + await waitFor(async function() { + expect(a).toEqual('a') + const el = await somethingAsync() + expect(b).toEqual('b') + }) + `, + errors: [ + { line: 5, column: 11, messageId: 'noWaitForMultipleAssertion' }, + ], + }, + ], +}); diff --git a/tests/lib/rules/no-wait-for-side-effects.test.ts b/tests/lib/rules/no-wait-for-side-effects.test.ts new file mode 100644 index 00000000..a7aa1c94 --- /dev/null +++ b/tests/lib/rules/no-wait-for-side-effects.test.ts @@ -0,0 +1,346 @@ +import { createRuleTester } from '../test-utils'; +import rule, { RULE_NAME } from '../../../lib/rules/no-wait-for-side-effects'; + +const ruleTester = createRuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => expect(a).toEqual('a')) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + expect(a).toEqual('a') + }) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + console.log('testing-library') + expect(b).toEqual('b') + }) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + console.log('testing-library') + expect(b).toEqual('b') + }) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => {}) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() {}) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + // testing + }) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + // testing + }) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + fireEvent.keyDown(input, {key: 'ArrowDown'}) + await waitFor(() => { + expect(b).toEqual('b') + }) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + fireEvent.keyDown(input, {key: 'ArrowDown'}) + await waitFor(function() { + expect(b).toEqual('b') + }) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + userEvent.click(button) + await waitFor(function() { + expect(b).toEqual('b') + }) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor } from 'somewhere-else'; + await waitFor(function() { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + expect(b).toEqual('b') + }) + `, + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + + anotherFunction(() => { + fireEvent.keyDown(input, {key: 'ArrowDown'}); + userEvent.click(button); + }); + + test('side effects in functions other than waitFor are valid', () => { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + userEvent.click(button) + expect(b).toEqual('b') + }); + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor } from 'somewhere-else'; + await waitFor(() => { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + }) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor as renamedWaitFor, fireEvent } from 'test-utils'; + import { waitFor, userEvent } from 'somewhere-else'; + + await waitFor(() => { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + userEvent.click(button) + }) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { waitFor, fireEvent as renamedFireEvent, userEvent as renamedUserEvent } from 'test-utils'; + import { fireEvent, userEvent } from 'somewhere-else'; + + await waitFor(() => { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + userEvent.click(button) + }) + `, + }, + { + code: `// weird case to cover 100% coverage + await waitFor(() => { + const click = firEvent['click'] + }) + `, + }, + ], + invalid: [ + // fireEvent + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor, fireEvent as renamedFireEvent } from '@testing-library/react'; + await waitFor(() => { + renamedFireEvent.keyDown(input, {key: 'ArrowDown'}) + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + settings: { 'testing-library/utils-module': '~/test-utils' }, + code: ` + import { waitFor, fireEvent } from '~/test-utils'; + await waitFor(() => { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + expect(b).toEqual('b') + fireEvent.keyDown(input, {key: 'ArrowDown'}) + }) + `, + errors: [{ line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + expect(b).toEqual('b') + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + expect(b).toEqual('b') + fireEvent.keyDown(input, {key: 'ArrowDown'}) + }) + `, + errors: [{ line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + fireEvent.keyDown(input, {key: 'ArrowDown'}) + expect(b).toEqual('b') + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + // userEvent + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + userEvent.click(button) + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + import renamedUserEvent from '@testing-library/user-event' + await waitFor(() => { + renamedUserEvent.click(button) + }) + `, + errors: [{ line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + settings: { 'testing-library/utils-module': '~/test-utils' }, + code: ` + import { waitFor } from '~/test-utils'; + import userEvent from '@testing-library/user-event' + await waitFor(() => { + userEvent.click(); + }) + `, + errors: [{ line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + expect(b).toEqual('b') + userEvent.click(button) + }) + `, + errors: [{ line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(() => { + userEvent.click(button) + expect(b).toEqual('b') + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + userEvent.click(button) + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + expect(b).toEqual('b') + userEvent.click(button) + }) + `, + errors: [{ line: 5, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + { + code: ` + import { waitFor } from '@testing-library/react'; + await waitFor(function() { + userEvent.click(button) + expect(b).toEqual('b') + }) + `, + errors: [{ line: 4, column: 11, messageId: 'noSideEffectsWaitFor' }], + }, + + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: `// all mixed + import { waitFor, fireEvent as renamedFireEvent, screen } from '~/test-utils'; + import userEvent from '@testing-library/user-event' + import { fireEvent } from 'somewhere-else' + + test('check all mixed', async () => { + const button = await screen.findByRole('button') + await waitFor(() => { + renamedFireEvent.keyDown(input, {key: 'ArrowDown'}) + expect(b).toEqual('b') + fireEvent.keyDown(input, {key: 'ArrowDown'}) + userEvent.click(button) + someBool ? 'a' : 'b' // cover expression statement without identifier for 100% coverage + }) + }) + `, + errors: [ + { line: 9, column: 13, messageId: 'noSideEffectsWaitFor' }, + { line: 12, column: 13, messageId: 'noSideEffectsWaitFor' }, + ], + }, + ], +}); diff --git a/tests/lib/rules/no-wait-for-snapshot.test.ts b/tests/lib/rules/no-wait-for-snapshot.test.ts index 5f489513..6aae1adc 100644 --- a/tests/lib/rules/no-wait-for-snapshot.test.ts +++ b/tests/lib/rules/no-wait-for-snapshot.test.ts @@ -6,7 +6,7 @@ const ruleTester = createRuleTester(); ruleTester.run(RULE_NAME, rule, { valid: [ - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls outside of ${asyncUtil} are valid', () => { @@ -16,7 +16,7 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls outside of ${asyncUtil} are valid', () => { @@ -28,7 +28,7 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls outside of ${asyncUtil} are valid', () => { @@ -38,7 +38,7 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls outside of ${asyncUtil} are valid', () => { @@ -50,91 +50,131 @@ ruleTester.run(RULE_NAME, rule, { }) `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` import { ${asyncUtil} } from 'some-other-library'; - test('snapshot calls within ${asyncUtil} are not valid', async () => { + test('aggressive reporting disabled - snapshot calls within ${asyncUtil} not related to Testing Library are valid', async () => { await ${asyncUtil}(() => expect(foo).toMatchSnapshot()); }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` import { ${asyncUtil} } from 'some-other-library'; - test('snapshot calls within ${asyncUtil} are not valid', async () => { + test('(alt) aggressive reporting disabled - snapshot calls within ${asyncUtil} not related to Testing Library are valid', async () => { await ${asyncUtil}(() => { - expect(foo).toMatchSnapshot() + // this alt version doesn't return from callback passed to async util + expect(foo).toMatchSnapshot() }); }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` import * as asyncUtils from 'some-other-library'; - test('snapshot calls within ${asyncUtil} are not valid', async () => { + test('aggressive reporting disabled - snapshot calls within ${asyncUtil} from wildcard import not related to Testing Library are valid', async () => { await asyncUtils.${asyncUtil}(() => expect(foo).toMatchSnapshot()); }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` import * as asyncUtils from 'some-other-library'; - test('snapshot calls within ${asyncUtil} are not valid', async () => { + test('(alt) aggressive reporting disabled - snapshot calls within ${asyncUtil} from wildcard import not related to Testing Library are valid', async () => { await asyncUtils.${asyncUtil}(() => { - expect(foo).toMatchSnapshot() + // this alt version doesn't return from callback passed to async util + expect(foo).toMatchSnapshot() }); }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` import { ${asyncUtil} } from 'some-other-library'; - test('snapshot calls within ${asyncUtil} are not valid', async () => { + test('aggressive reporting disabled - inline snapshot calls within ${asyncUtil} import not related to Testing Library are valid', async () => { await ${asyncUtil}(() => expect(foo).toMatchInlineSnapshot()); }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` import { ${asyncUtil} } from 'some-other-library'; - test('snapshot calls within ${asyncUtil} are not valid', async () => { + test('(alt) aggressive reporting disabled - inline snapshot calls within ${asyncUtil} import not related to Testing Library are valid', async () => { await ${asyncUtil}(() => { - expect(foo).toMatchInlineSnapshot() + // this alt version doesn't return from callback passed to async util + expect(foo).toMatchInlineSnapshot() }); }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` import * as asyncUtils from 'some-other-library'; - test('snapshot calls within ${asyncUtil} are not valid', async () => { + test('aggressive reporting disabled - inline snapshot calls within ${asyncUtil} from wildcard import not related to Testing Library are valid', async () => { await asyncUtils.${asyncUtil}(() => expect(foo).toMatchInlineSnapshot()); }); `, })), - ...ASYNC_UTILS.map(asyncUtil => ({ + ...ASYNC_UTILS.map((asyncUtil) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` import * as asyncUtils from 'some-other-library'; - test('snapshot calls within ${asyncUtil} are not valid', async () => { + test('(alt) aggressive reporting disabled - inline snapshot calls within ${asyncUtil} from wildcard import not related to Testing Library are valid', async () => { await asyncUtils.${asyncUtil}(() => { - expect(foo).toMatchInlineSnapshot() + // this alt version doesn't return from callback passed to async util + expect(foo).toMatchInlineSnapshot() }); }); `, })), ], invalid: [ - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { await ${asyncUtil}(() => expect(foo).toMatchSnapshot()); }); `, - errors: [{ line: 4, messageId: 'noWaitForSnapshot' }], - })), - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + errors: [ + { + line: 4, + messageId: 'noWaitForSnapshot', + data: { name: asyncUtil }, + column: 36 + asyncUtil.length, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { await ${asyncUtil}(() => { @@ -142,19 +182,39 @@ ruleTester.run(RULE_NAME, rule, { }); }); `, - errors: [{ line: 5, messageId: 'noWaitForSnapshot' }], - })), - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + errors: [ + { + line: 5, + messageId: 'noWaitForSnapshot', + data: { name: asyncUtil }, + column: 27, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { await asyncUtils.${asyncUtil}(() => expect(foo).toMatchSnapshot()); }); `, - errors: [{ line: 4, messageId: 'noWaitForSnapshot' }], - })), - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + errors: [ + { + line: 4, + messageId: 'noWaitForSnapshot', + data: { name: asyncUtil }, + column: 47 + asyncUtil.length, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { await asyncUtils.${asyncUtil}(() => { @@ -162,19 +222,39 @@ ruleTester.run(RULE_NAME, rule, { }); }); `, - errors: [{ line: 5, messageId: 'noWaitForSnapshot' }], - })), - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + errors: [ + { + line: 5, + messageId: 'noWaitForSnapshot', + data: { name: asyncUtil }, + column: 27, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { await ${asyncUtil}(() => expect(foo).toMatchInlineSnapshot()); }); `, - errors: [{ line: 4, messageId: 'noWaitForSnapshot' }], - })), - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + errors: [ + { + line: 4, + messageId: 'noWaitForSnapshot', + data: { name: asyncUtil }, + column: 36 + asyncUtil.length, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import { ${asyncUtil} } from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { await ${asyncUtil}(() => { @@ -182,19 +262,39 @@ ruleTester.run(RULE_NAME, rule, { }); }); `, - errors: [{ line: 5, messageId: 'noWaitForSnapshot' }], - })), - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + errors: [ + { + line: 5, + messageId: 'noWaitForSnapshot', + data: { name: asyncUtil }, + column: 27, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { await asyncUtils.${asyncUtil}(() => expect(foo).toMatchInlineSnapshot()); }); `, - errors: [{ line: 4, messageId: 'noWaitForSnapshot' }], - })), - ...ASYNC_UTILS.map(asyncUtil => ({ - code: ` + errors: [ + { + line: 4, + messageId: 'noWaitForSnapshot', + data: { name: asyncUtil }, + column: 47 + asyncUtil.length, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` import * as asyncUtils from '@testing-library/dom'; test('snapshot calls within ${asyncUtil} are not valid', async () => { await asyncUtils.${asyncUtil}(() => { @@ -202,7 +302,15 @@ ruleTester.run(RULE_NAME, rule, { }); }); `, - errors: [{ line: 5, messageId: 'noWaitForSnapshot' }], - })), + errors: [ + { + line: 5, + messageId: 'noWaitForSnapshot', + data: { name: asyncUtil }, + column: 27, + }, + ], + } as const) + ), ], }); diff --git a/tests/lib/rules/prefer-explicit-assert.test.ts b/tests/lib/rules/prefer-explicit-assert.test.ts index 0ebef887..11cdf141 100644 --- a/tests/lib/rules/prefer-explicit-assert.test.ts +++ b/tests/lib/rules/prefer-explicit-assert.test.ts @@ -4,196 +4,240 @@ import { ALL_QUERIES_METHODS } from '../../../lib/utils'; const ruleTester = createRuleTester(); +const COMBINED_QUERIES_METHODS = [...ALL_QUERIES_METHODS, 'ByIcon']; + ruleTester.run(RULE_NAME, rule, { valid: [ - { - code: `getByText`, - }, - { - code: `const utils = render() - - utils.getByText + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `get${queryMethod}('Hello')`, + settings: { + 'testing-library/utils-module': 'test-utils', + }, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `get${queryMethod}`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: ` + const utils = render() + utils.get${queryMethod} `, - }, - { - code: `expect(getByText('foo')).toBeDefined()`, - }, - { - code: `const utils = render() - - expect(utils.getByText('foo')).toBeDefined() + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `screen.get${queryMethod}`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `expect(get${queryMethod}('foo')).toBeDefined()`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: ` + const utils = render() + expect(utils.get${queryMethod}('foo')).toBeDefined() + `, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `expect(screen.get${queryMethod}('foo')).toBeDefined()`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `expect(getBy${queryMethod}('foo').bar).toBeInTheDocument()`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: ` + async () => { + await waitForElement(() => get${queryMethod}('foo')) + } + `, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `fireEvent.click(get${queryMethod}('bar'));`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `const quxElement = get${queryMethod}('qux')`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `() => { return get${queryMethod}('foo') }`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `function bar() { return get${queryMethod}('foo') }`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `const { get${queryMethod} } = render()`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `it('test', () => { const { get${queryMethod} } = render() })`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `it('test', () => { const [ get${queryMethod} ] = render() })`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `const a = [ get${queryMethod}('foo') ]`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `const a = { foo: get${queryMethod}('bar') }`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `query${queryMethod}("foo")`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: ` + expect(get${queryMethod}('foo')).toBeTruthy() + fireEvent.click(get${queryMethod}('bar')); `, - }, - { - code: `expect(getByText('foo')).toBeInTheDocument();`, - }, - { - code: `expect(getByText('foo').bar).toBeInTheDocument()`, - }, - { - code: `async () => { await waitForElement(() => getByText('foo')) }`, - }, - { - code: `fireEvent.click(getByText('bar'));`, - }, - { - code: `const quxElement = getByText('qux')`, - }, - { - code: `() => { return getByText('foo') }`, - }, - { - code: `function bar() { return getByText('foo') }`, - }, - { - code: `getByIcon('foo')`, // custom `getBy` query not extended through options - }, - { - code: `const { getByText } = render()`, - }, - { - code: `it('test', () => { const { getByText } = render() })`, - }, - { - code: `it('test', () => { const [ getByText ] = render() })`, - }, - { - code: `const a = [ getByText('foo') ]`, - }, - { - code: `const a = { foo: getByText('bar') }`, - }, - { - code: `queryByText("foo")`, - }, - { - code: `expect(getByText('foo')).toBeTruthy() - - fireEvent.click(getByText('bar'));`, options: [ { assertion: 'toBeTruthy', }, ], - }, - { - code: `expect(getByText('foo')).toBeEnabled()`, + })), + ...COMBINED_QUERIES_METHODS.map((queryMethod) => ({ + code: `expect(get${queryMethod}('foo')).toBeEnabled()`, options: [ { assertion: 'toBeInTheDocument', }, ], - }, + })), ], - invalid: [ - ...ALL_QUERIES_METHODS.map(queryMethod => ({ - code: `get${queryMethod}('foo')`, - errors: [ - { - messageId: 'preferExplicitAssert', - }, - ], - })), - ...ALL_QUERIES_METHODS.map(queryMethod => ({ - code: `const utils = render() - - utils.get${queryMethod}('foo')`, - errors: [ - { - messageId: 'preferExplicitAssert', - line: 3, - column: 13, - }, - ], - })), - ...ALL_QUERIES_METHODS.map(queryMethod => ({ - code: `() => { - get${queryMethod}('foo') - doSomething() + ...COMBINED_QUERIES_METHODS.map( + (queryMethod) => + ({ + code: `get${queryMethod}('foo')`, + errors: [ + { + messageId: 'preferExplicitAssert', + }, + ], + } as const) + ), + ...COMBINED_QUERIES_METHODS.map( + (queryMethod) => + ({ + code: ` + const utils = render() + utils.get${queryMethod}('foo') + `, + errors: [ + { + messageId: 'preferExplicitAssert', + line: 3, + column: 15, + }, + ], + } as const) + ), + ...COMBINED_QUERIES_METHODS.map( + (queryMethod) => + ({ + code: `screen.get${queryMethod}('foo')`, + errors: [ + { + messageId: 'preferExplicitAssert', + line: 1, + column: 8, + }, + ], + } as const) + ), + ...COMBINED_QUERIES_METHODS.map( + (queryMethod) => + ({ + code: ` + () => { + get${queryMethod}('foo') + doSomething() - get${queryMethod}('bar') - const quxElement = get${queryMethod}('qux') - } + get${queryMethod}('bar') + const quxElement = get${queryMethod}('qux') + } `, - errors: [ - { - messageId: 'preferExplicitAssert', - line: 2, - }, - { - messageId: 'preferExplicitAssert', - line: 5, - }, - ], - })), - // for coverage - { - code: `getByText("foo")`, - options: [{ customQueryNames: ['bar'] }], - errors: [ - { - messageId: 'preferExplicitAssert', - }, - ], - }, + errors: [ + { + messageId: 'preferExplicitAssert', + line: 3, + }, + { + messageId: 'preferExplicitAssert', + line: 6, + }, + ], + } as const) + ), + ...COMBINED_QUERIES_METHODS.map( + (queryMethod) => + ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import "test-utils" + getBy${queryMethod}("Hello") + `, + errors: [ + { + messageId: 'preferExplicitAssert', + }, + ], + } as const) + ), { code: `getByIcon('foo')`, // custom `getBy` query extended through options - options: [ - { - customQueryNames: ['getByIcon'], - }, - ], errors: [ { messageId: 'preferExplicitAssert', }, ], }, - { - code: `expect(getByText('foo')).toBeDefined()`, - options: [ - { - assertion: 'toBeInTheDocument', - }, - ], - errors: [ - { - messageId: 'preferExplicitAssertAssertion', - column: 26, - data: { assertion: 'toBeInTheDocument' }, - }, - ], - }, - { - code: `expect(getByText('foo')).not.toBeNull()`, - options: [ - { - assertion: 'toBeInTheDocument', - }, - ], - errors: [ - { - messageId: 'preferExplicitAssertAssertion', - column: 26, - data: { assertion: 'toBeInTheDocument' }, - }, - ], - }, - { - code: `expect(getByText('foo')).not.toBeFalsy()`, - options: [ - { - assertion: 'toBeInTheDocument', - }, - ], - errors: [ - { - messageId: 'preferExplicitAssertAssertion', - column: 26, - data: { assertion: 'toBeInTheDocument' }, - }, - ], - }, + ...COMBINED_QUERIES_METHODS.map( + (queryMethod) => + ({ + code: `expect(get${queryMethod}('foo')).toBeDefined()`, + options: [ + { + assertion: 'toBeInTheDocument', + }, + ], + errors: [ + { + messageId: 'preferExplicitAssertAssertion', + data: { assertion: 'toBeInTheDocument' }, + }, + ], + } as const) + ), + ...COMBINED_QUERIES_METHODS.map( + (queryMethod) => + ({ + code: `expect(get${queryMethod}('foo')).not.toBeNull()`, + options: [ + { + assertion: 'toBeInTheDocument', + }, + ], + errors: [ + { + messageId: 'preferExplicitAssertAssertion', + data: { assertion: 'toBeInTheDocument' }, + }, + ], + } as const) + ), + ...COMBINED_QUERIES_METHODS.map( + (queryMethod) => + ({ + code: `expect(get${queryMethod}('foo')).not.toBeFalsy()`, + options: [ + { + assertion: 'toBeInTheDocument', + }, + ], + errors: [ + { + messageId: 'preferExplicitAssertAssertion', + data: { assertion: 'toBeInTheDocument' }, + }, + ], + } as const) + ), ], }); diff --git a/tests/lib/rules/prefer-find-by.test.ts b/tests/lib/rules/prefer-find-by.test.ts index 2e64829e..96e892e7 100644 --- a/tests/lib/rules/prefer-find-by.test.ts +++ b/tests/lib/rules/prefer-find-by.test.ts @@ -14,11 +14,7 @@ import rule, { MessageIds, } from '../../../lib/rules/prefer-find-by'; -const ruleTester = createRuleTester({ - ecmaFeatures: { - jsx: true, - }, -}); +const ruleTester = createRuleTester(); function buildFindByMethod(queryMethod: string) { return `${getFindByQueryVariant(queryMethod)}${queryMethod.split('By')[1]}`; @@ -30,7 +26,7 @@ function createScenario< return WAIT_METHODS.reduce( (acc: T[], waitMethod) => acc.concat( - SYNC_QUERIES_COMBINATIONS.map(queryMethod => + SYNC_QUERIES_COMBINATIONS.map((queryMethod) => callback(waitMethod, queryMethod) ) ), @@ -40,62 +36,112 @@ function createScenario< ruleTester.run(RULE_NAME, rule, { valid: [ - ...ASYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...ASYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: ` - const { ${queryMethod} } = setup() - const submitButton = await ${queryMethod}('foo') + it('tests', async () => { + const { ${queryMethod} } = setup() + const submitButton = await ${queryMethod}('foo') + }) `, })), - ...ASYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: `const submitButton = await screen.${queryMethod}('foo')`, + ...ASYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ + code: ` + import {screen} from '@testing-library/foo'; + it('tests', async () => { + const submitButton = await screen.${queryMethod}('foo') + }) + `, })), - ...SYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: `await waitForElementToBeRemoved(() => ${queryMethod}(baz))`, + ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ + code: ` + import {waitForElementToBeRemoved} from '@testing-library/foo'; + it('tests', async () => { + await waitForElementToBeRemoved(() => ${queryMethod}(baz)) + }) + `, })), - ...SYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: `await waitFor(function() { - return ${queryMethod}('baz', { name: 'foo' }) - })`, + ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ + code: ` + import {waitFor} from '@testing-library/foo'; + + it('tests', async () => { + await waitFor(function() { + return ${queryMethod}('baz', { name: 'foo' }) + }) + }) + `, })), { - code: `await waitFor(() => myCustomFunction())`, + code: ` + import {waitFor} from '@testing-library/foo'; + + it('tests', async () => { + await waitFor(() => myCustomFunction()) + }) + `, }, { - code: `await waitFor(customFunctionReference)`, + code: ` + import {waitFor} from '@testing-library/foo'; + it('tests', async () => { + await waitFor(customFunctionReference) + }) + `, }, { - code: `await waitForElementToBeRemoved(document.querySelector('foo'))`, + code: ` + import {waitForElementToBeRemoved} from '@testing-library/foo'; + it('tests', async () => { + const { container } = render() + await waitForElementToBeRemoved(container.querySelector('foo')) + }) + `, }, - ...SYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: ` - await waitFor(() => { - foo() - return ${queryMethod}() + import {waitFor} from '@testing-library/foo'; + it('tests', async () => { + await waitFor(() => { + foo() + return ${queryMethod}() + }) }) `, })), - ...SYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: ` - await waitFor(() => expect(screen.${queryMethod}('baz')).toBeDisabled()); + import {screen, waitFor} from '@testing-library/foo'; + it('tests', async () => { + await waitFor(() => expect(screen.${queryMethod}('baz')).toBeDisabled()); + }) `, })), - ...SYNC_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: ` - await waitFor(() => expect(${queryMethod}('baz')).toBeInTheDocument()); + import {waitFor} from '@testing-library/foo'; + it('tests', async () => { + await waitFor(() => expect(${queryMethod}('baz')).toBeInTheDocument()); + }) `, })), { code: ` - await waitFor(); - await wait(); + import {waitFor} from '@testing-library/foo'; + it('tests', async () => { + await waitFor(); + await wait(); + }) `, }, ], invalid: [ ...createScenario((waitMethod: string, queryMethod: string) => ({ code: ` - const { ${queryMethod} } = render() - const submitButton = await ${waitMethod}(() => ${queryMethod}('foo', { name: 'baz' })) + import {${waitMethod}} from '@testing-library/foo'; + it('tests', async () => { + const { ${queryMethod} } = render() + const submitButton = await ${waitMethod}(() => ${queryMethod}('foo', { name: 'baz' })) + }) `, errors: [ { @@ -103,87 +149,110 @@ ruleTester.run(RULE_NAME, rule, { data: { queryVariant: getFindByQueryVariant(queryMethod), queryMethod: queryMethod.split('By')[1], - fullQuery: `${waitMethod}(() => ${queryMethod}('foo', { name: 'baz' }))`, + prevQuery: queryMethod, + waitForMethodName: waitMethod, }, }, ], output: ` - const { ${queryMethod}, ${buildFindByMethod(queryMethod)} } = render() - const submitButton = await ${buildFindByMethod( - queryMethod - )}('foo', { name: 'baz' }) + import {${waitMethod}} from '@testing-library/foo'; + it('tests', async () => { + const { ${queryMethod}, ${buildFindByMethod(queryMethod)} } = render() + const submitButton = await ${buildFindByMethod( + queryMethod + )}('foo', { name: 'baz' }) + }) `, })), ...createScenario((waitMethod: string, queryMethod: string) => ({ - code: `const submitButton = await ${waitMethod}(() => screen.${queryMethod}('foo', { name: 'baz' }))`, + code: ` + import {${waitMethod}, screen} from '@testing-library/foo'; + it('tests', async () => { + const submitButton = await ${waitMethod}(() => screen.${queryMethod}('foo', { name: 'baz' })) + }) + `, errors: [ { messageId: 'preferFindBy', data: { queryVariant: getFindByQueryVariant(queryMethod), queryMethod: queryMethod.split('By')[1], - fullQuery: `${waitMethod}(() => screen.${queryMethod}('foo', { name: 'baz' }))`, + prevQuery: queryMethod, + waitForMethodName: waitMethod, }, }, ], - output: `const submitButton = await screen.${buildFindByMethod( - queryMethod - )}('foo', { name: 'baz' })`, + output: ` + import {${waitMethod}, screen} from '@testing-library/foo'; + it('tests', async () => { + const submitButton = await screen.${buildFindByMethod( + queryMethod + )}('foo', { name: 'baz' }) + }) + `, })), // // this scenario verifies it works when the render function is defined in another scope - ...WAIT_METHODS.map((waitMethod: string) => ({ - code: ` + ...WAIT_METHODS.map( + (waitMethod: string) => + ({ + code: ` + import {${waitMethod}} from '@testing-library/foo'; const { getByText, queryByLabelText, findAllByRole } = customRender() - it('foo', async () => { + it('tests', async () => { const submitButton = await ${waitMethod}(() => getByText('baz', { name: 'button' })) }) `, - errors: [ - { - messageId: 'preferFindBy', - data: { - queryVariant: 'findBy', - queryMethod: 'Text', - fullQuery: `${waitMethod}(() => getByText('baz', { name: 'button' }))`, - }, - }, - ], - output: ` + errors: [ + { + messageId: 'preferFindBy', + data: { + queryVariant: 'findBy', + queryMethod: 'Text', + prevQuery: 'getByText', + waitForMethodName: waitMethod, + }, + }, + ], + output: ` + import {${waitMethod}} from '@testing-library/foo'; const { getByText, queryByLabelText, findAllByRole, findByText } = customRender() - it('foo', async () => { + it('tests', async () => { const submitButton = await findByText('baz', { name: 'button' }) }) `, - })), + } as const) + ), // // this scenario verifies when findBy* were already defined (because it was used elsewhere) - ...WAIT_METHODS.map((waitMethod: string) => ({ - code: ` + ...WAIT_METHODS.map( + (waitMethod: string) => + ({ + code: ` + import {${waitMethod}} from '@testing-library/foo'; const { getAllByRole, findAllByRole } = customRender() - describe('some scenario', () => { - it('foo', async () => { - const submitButton = await ${waitMethod}(() => getAllByRole('baz', { name: 'button' })) - }) + it('tests', async () => { + const submitButton = await ${waitMethod}(() => getAllByRole('baz', { name: 'button' })) }) `, - errors: [ - { - messageId: 'preferFindBy', - data: { - queryVariant: 'findAllBy', - queryMethod: 'Role', - fullQuery: `${waitMethod}(() => getAllByRole('baz', { name: 'button' }))`, - }, - }, - ], - output: ` + errors: [ + { + messageId: 'preferFindBy', + data: { + queryVariant: 'findAllBy', + queryMethod: 'Role', + prevQuery: 'getAllByRole', + waitForMethodName: waitMethod, + }, + }, + ], + output: ` + import {${waitMethod}} from '@testing-library/foo'; const { getAllByRole, findAllByRole } = customRender() - describe('some scenario', () => { - it('foo', async () => { - const submitButton = await findAllByRole('baz', { name: 'button' }) - }) + it('tests', async () => { + const submitButton = await findAllByRole('baz', { name: 'button' }) }) `, - })), + } as const) + ), // invalid code, as we need findBy* to be defined somewhere, but required for getting 100% coverage { code: `const submitButton = await waitFor(() => getByText('baz', { name: 'button' }))`, @@ -193,7 +262,8 @@ ruleTester.run(RULE_NAME, rule, { data: { queryVariant: 'findBy', queryMethod: 'Text', - fullQuery: `waitFor(() => getByText('baz', { name: 'button' }))`, + prevQuery: 'getByText', + waitForMethodName: 'waitFor', }, }, ], @@ -211,7 +281,8 @@ ruleTester.run(RULE_NAME, rule, { data: { queryVariant: 'findBy', queryMethod: 'Role', - fullQuery: `waitFor(() => getByRole('baz', { name: 'button' }))`, + prevQuery: 'getByRole', + waitForMethodName: 'waitFor', }, }, ], @@ -220,5 +291,67 @@ ruleTester.run(RULE_NAME, rule, { const submitButton = await findByRole('baz', { name: 'button' }) `, }, + // custom query triggers the error but there is no fix - so output is the same + ...WAIT_METHODS.map( + (waitMethod: string) => + ({ + code: ` + import {${waitMethod},render} from '@testing-library/foo'; + it('tests', async () => { + const { getByCustomQuery } = render() + const submitButton = await ${waitMethod}(() => getByCustomQuery('baz')) + }) + `, + errors: [ + { + messageId: 'preferFindBy', + data: { + queryVariant: 'findBy', + queryMethod: 'CustomQuery', + prevQuery: 'getByCustomQuery', + waitForMethodName: waitMethod, + }, + }, + ], + output: ` + import {${waitMethod},render} from '@testing-library/foo'; + it('tests', async () => { + const { getByCustomQuery } = render() + const submitButton = await ${waitMethod}(() => getByCustomQuery('baz')) + }) + `, + } as const) + ), + // custom query triggers the error but there is no fix - so output is the same + ...WAIT_METHODS.map( + (waitMethod: string) => + ({ + code: ` + import {${waitMethod},render,screen} from '@testing-library/foo'; + it('tests', async () => { + const { getByCustomQuery } = render() + const submitButton = await ${waitMethod}(() => screen.getByCustomQuery('baz')) + }) + `, + errors: [ + { + messageId: 'preferFindBy', + data: { + queryVariant: 'findBy', + queryMethod: 'CustomQuery', + prevQuery: 'getByCustomQuery', + waitForMethodName: waitMethod, + }, + }, + ], + output: ` + import {${waitMethod},render,screen} from '@testing-library/foo'; + it('tests', async () => { + const { getByCustomQuery } = render() + const submitButton = await ${waitMethod}(() => screen.getByCustomQuery('baz')) + }) + `, + } as const) + ), ], }); diff --git a/tests/lib/rules/prefer-presence-queries.test.ts b/tests/lib/rules/prefer-presence-queries.test.ts index 2a5dd09c..296b6903 100644 --- a/tests/lib/rules/prefer-presence-queries.test.ts +++ b/tests/lib/rules/prefer-presence-queries.test.ts @@ -4,91 +4,343 @@ import rule, { MessageIds, } from '../../../lib/rules/prefer-presence-queries'; import { ALL_QUERIES_METHODS } from '../../../lib/utils'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; const ruleTester = createRuleTester(); -const getByQueries = ALL_QUERIES_METHODS.map(method => `get${method}`); -const getAllByQueries = ALL_QUERIES_METHODS.map(method => `getAll${method}`); -const queryByQueries = ALL_QUERIES_METHODS.map(method => `query${method}`); +const getByQueries = ALL_QUERIES_METHODS.map((method) => `get${method}`); +const getAllByQueries = ALL_QUERIES_METHODS.map((method) => `getAll${method}`); +const queryByQueries = ALL_QUERIES_METHODS.map((method) => `query${method}`); const queryAllByQueries = ALL_QUERIES_METHODS.map( - method => `queryAll${method}` + (method) => `queryAll${method}` ); -const allQueryUseInAssertion = (queryName: string) => [ - queryName, - `screen.${queryName}`, -]; +type RuleValidTestCase = TSESLint.ValidTestCase<[]>; +type RuleInvalidTestCase = TSESLint.InvalidTestCase; -const getValidAssertion = (query: string, matcher: string) => - allQueryUseInAssertion(query).map(query => ({ - code: `expect(${query}('Hello'))${matcher}`, - })); +type AssertionFnParams = { + query: string; + matcher: string; + messageId: MessageIds; + shouldUseScreen?: boolean; +}; -const getInvalidAssertion = ( - query: string, - matcher: string, - messageId: MessageIds -) => - allQueryUseInAssertion(query).map(query => ({ - code: `expect(${query}('Hello'))${matcher}`, - errors: [{ messageId }], - })); +const getValidAssertion = ({ + query, + matcher, + shouldUseScreen = false, +}: Omit): RuleValidTestCase => { + const finalQuery = shouldUseScreen ? `screen.${query}` : query; + return { + code: `expect(${finalQuery}('Hello'))${matcher}`, + } as const; +}; + +const getInvalidAssertion = ({ + query, + matcher, + messageId, + shouldUseScreen = false, +}: AssertionFnParams): RuleInvalidTestCase => { + const finalQuery = shouldUseScreen ? `screen.${query}` : query; + return { + code: `expect(${finalQuery}('Hello'))${matcher}`, + errors: [{ messageId, line: 1, column: shouldUseScreen ? 15 : 8 }], + }; +}; ruleTester.run(RULE_NAME, rule, { valid: [ - ...getByQueries.reduce( + // cases: methods not matching Testing Library queries pattern + `expect(queryElement('foo')).toBeInTheDocument()`, + `expect(getElement('foo')).not.toBeInTheDocument()`, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: invalid presence assert but not reported because custom module is not imported + expect(queryByRole('button')).toBeInTheDocument() + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: invalid absence assert but not reported because custom module is not imported + expect(getByRole('button')).not.toBeInTheDocument() + `, + }, + // cases: asserting presence correctly with `getBy*` queries + ...getByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getValidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + }), + getValidAssertion({ query: queryName, matcher: '.toBeTruthy()' }), + getValidAssertion({ query: queryName, matcher: '.toBeDefined()' }), + getValidAssertion({ query: queryName, matcher: '.toBe("foo")' }), + getValidAssertion({ query: queryName, matcher: '.toEqual("World")' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeFalsy()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeNull()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeDisabled()' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + }), + ], + [] + ), + // cases: asserting presence correctly with `screen.getBy*` queries + ...getByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getValidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBe("foo")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toEqual("World")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeDisabled()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + shouldUseScreen: true, + }), + ], + [] + ), + // cases: asserting presence correctly with `getAllBy*` queries + ...getAllByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getValidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + }), + getValidAssertion({ query: queryName, matcher: '.toBeTruthy()' }), + getValidAssertion({ query: queryName, matcher: '.toBeDefined()' }), + getValidAssertion({ query: queryName, matcher: '.toBe("foo")' }), + getValidAssertion({ query: queryName, matcher: '.toEqual("World")' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeFalsy()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeNull()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeDisabled()' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + }), + ], + [] + ), + // cases: asserting presence correctly with `screen.getAllBy*` queries + ...getAllByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getValidAssertion(queryName, '.toBeInTheDocument()'), - ...getValidAssertion(queryName, '.toBeTruthy()'), - ...getValidAssertion(queryName, '.toBeDefined()'), - ...getValidAssertion(queryName, '.toBe("foo")'), - ...getValidAssertion(queryName, '.toEqual("World")'), - ...getValidAssertion(queryName, '.not.toBeFalsy()'), - ...getValidAssertion(queryName, '.not.toBeNull()'), - ...getValidAssertion(queryName, '.not.toBeDisabled()'), - ...getValidAssertion(queryName, '.not.toHaveClass("btn")'), + getValidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBe("foo")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toEqual("World")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeDisabled()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + shouldUseScreen: true, + }), ], [] ), - ...getAllByQueries.reduce( + // cases: asserting absence correctly with `queryBy*` queries + ...queryByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getValidAssertion(queryName, '.toBeInTheDocument()'), - ...getValidAssertion(queryName, '.toBeTruthy()'), - ...getValidAssertion(queryName, '.toBeDefined()'), - ...getValidAssertion(queryName, '.toBe("foo")'), - ...getValidAssertion(queryName, '.toEqual("World")'), - ...getValidAssertion(queryName, '.not.toBeFalsy()'), - ...getValidAssertion(queryName, '.not.toBeNull()'), - ...getValidAssertion(queryName, '.not.toBeDisabled()'), - ...getValidAssertion(queryName, '.not.toHaveClass("btn")'), + getValidAssertion({ query: queryName, matcher: '.toBeNull()' }), + getValidAssertion({ query: queryName, matcher: '.toBeFalsy()' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + }), + getValidAssertion({ query: queryName, matcher: '.not.toBeTruthy()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeDefined()' }), + getValidAssertion({ query: queryName, matcher: '.toEqual("World")' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + }), ], [] ), - ...queryByQueries.reduce( + // cases: asserting absence correctly with `screen.queryBy*` queries + ...queryByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getValidAssertion(queryName, '.toBeNull()'), - ...getValidAssertion(queryName, '.toBeFalsy()'), - ...getValidAssertion(queryName, '.not.toBeInTheDocument()'), - ...getValidAssertion(queryName, '.not.toBeTruthy()'), - ...getValidAssertion(queryName, '.not.toBeDefined()'), - ...getValidAssertion(queryName, '.toEqual("World")'), - ...getValidAssertion(queryName, '.not.toHaveClass("btn")'), + getValidAssertion({ + query: queryName, + matcher: '.toBeNull()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toEqual("World")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + shouldUseScreen: true, + }), ], [] ), - ...queryAllByQueries.reduce( + // cases: asserting absence correctly with `queryAllBy*` queries + ...queryAllByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getValidAssertion(queryName, '.toBeNull()'), - ...getValidAssertion(queryName, '.toBeFalsy()'), - ...getValidAssertion(queryName, '.not.toBeInTheDocument()'), - ...getValidAssertion(queryName, '.not.toBeTruthy()'), - ...getValidAssertion(queryName, '.not.toBeDefined()'), - ...getValidAssertion(queryName, '.toEqual("World")'), - ...getValidAssertion(queryName, '.not.toHaveClass("btn")'), + getValidAssertion({ query: queryName, matcher: '.toBeNull()' }), + getValidAssertion({ query: queryName, matcher: '.toBeFalsy()' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + }), + getValidAssertion({ query: queryName, matcher: '.not.toBeTruthy()' }), + getValidAssertion({ query: queryName, matcher: '.not.toBeDefined()' }), + getValidAssertion({ query: queryName, matcher: '.toEqual("World")' }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + }), + ], + [] + ), + // cases: asserting absence correctly with `screen.queryAllBy*` queries + ...queryAllByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getValidAssertion({ + query: queryName, + matcher: '.toBeNull()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.toEqual("World")', + shouldUseScreen: true, + }), + getValidAssertion({ + query: queryName, + matcher: '.not.toHaveClass("btn")', + shouldUseScreen: true, + }), ], [] ), @@ -98,96 +350,343 @@ ruleTester.run(RULE_NAME, rule, { { code: 'const el = queryByText("button")', }, - { - code: - 'expect(getByNonTestingLibraryQuery("button")).not.toBeInTheDocument()', - }, - { - code: - 'expect(queryByNonTestingLibraryQuery("button")).toBeInTheDocument()', - }, { code: `async () => { const el = await findByText('button') expect(el).toBeInTheDocument() }`, }, - // some weird examples after here to check guard against parent nodes - { - code: 'expect(getByText("button")).not()', - }, - { - code: 'expect(queryByText("button")).not()', - }, + `// case: query an element with getBy but then check its absence after doing + // some action which makes it disappear. + + // submit button exists + const submitButton = screen.getByRole('button') + fireEvent.click(submitButton) + + // right after clicking submit button it disappears + expect(submitButton).not.toBeInTheDocument() + `, ], invalid: [ - ...getByQueries.reduce( + // cases: asserting absence incorrectly with `getBy*` queries + ...getByQueries.reduce( (invalidRules, queryName) => [ ...invalidRules, - ...getInvalidAssertion(queryName, '.toBeNull()', 'absenceQuery'), - ...getInvalidAssertion(queryName, '.toBeFalsy()', 'absenceQuery'), - ...getInvalidAssertion( - queryName, - '.not.toBeInTheDocument()', - 'absenceQuery' - ), - ...getInvalidAssertion(queryName, '.not.toBeTruthy()', 'absenceQuery'), - ...getInvalidAssertion(queryName, '.not.toBeDefined()', 'absenceQuery'), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeNull()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + messageId: 'wrongAbsenceQuery', + }), ], [] ), - ...getAllByQueries.reduce( + // cases: asserting absence incorrectly with `screen.getBy*` queries + ...getByQueries.reduce( (invalidRules, queryName) => [ ...invalidRules, - ...getInvalidAssertion(queryName, '.toBeNull()', 'absenceQuery'), - ...getInvalidAssertion(queryName, '.toBeFalsy()', 'absenceQuery'), - ...getInvalidAssertion( - queryName, - '.not.toBeInTheDocument()', - 'absenceQuery' - ), - ...getInvalidAssertion(queryName, '.not.toBeTruthy()', 'absenceQuery'), - ...getInvalidAssertion(queryName, '.not.toBeDefined()', 'absenceQuery'), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeNull()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), ], [] ), - { - code: 'expect(screen.getAllByText("button")[1]).not.toBeInTheDocument()', - errors: [{ messageId: 'absenceQuery' }], - }, - ...queryByQueries.reduce( + // cases: asserting absence incorrectly with `getAllBy*` queries + ...getAllByQueries.reduce( + (invalidRules, queryName) => [ + ...invalidRules, + getInvalidAssertion({ + query: queryName, + matcher: '.toBeNull()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + messageId: 'wrongAbsenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + messageId: 'wrongAbsenceQuery', + }), + ], + [] + ), + // cases: asserting absence incorrectly with `screen.getAllBy*` queries + ...getAllByQueries.reduce( + (invalidRules, queryName) => [ + ...invalidRules, + getInvalidAssertion({ + query: queryName, + matcher: '.toBeNull()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeFalsy()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeInTheDocument()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeTruthy()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeDefined()', + messageId: 'wrongAbsenceQuery', + shouldUseScreen: true, + }), + ], + [] + ), + // cases: asserting presence incorrectly with `queryBy*` queries + ...queryByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getInvalidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + messageId: 'wrongPresenceQuery', + }), + ], + [] + ), + // cases: asserting presence incorrectly with `screen.queryBy*` queries + ...queryByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getInvalidAssertion(queryName, '.toBeTruthy()', 'presenceQuery'), - ...getInvalidAssertion(queryName, '.toBeDefined()', 'presenceQuery'), - ...getInvalidAssertion( - queryName, - '.toBeInTheDocument()', - 'presenceQuery' - ), - ...getInvalidAssertion(queryName, '.not.toBeFalsy()', 'presenceQuery'), - ...getInvalidAssertion(queryName, '.not.toBeNull()', 'presenceQuery'), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), ], [] ), - ...queryAllByQueries.reduce( + // cases: asserting presence incorrectly with `queryAllBy*` queries + ...queryAllByQueries.reduce( (validRules, queryName) => [ ...validRules, - ...getInvalidAssertion(queryName, '.toBeTruthy()', 'presenceQuery'), - ...getInvalidAssertion(queryName, '.toBeDefined()', 'presenceQuery'), - ...getInvalidAssertion( - queryName, - '.toBeInTheDocument()', - 'presenceQuery' - ), - ...getInvalidAssertion(queryName, '.not.toBeFalsy()', 'presenceQuery'), - ...getInvalidAssertion(queryName, '.not.toBeNull()', 'presenceQuery'), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + messageId: 'wrongPresenceQuery', + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + messageId: 'wrongPresenceQuery', + }), ], [] ), + // cases: asserting presence incorrectly with `screen.queryAllBy*` queries + ...queryAllByQueries.reduce( + (validRules, queryName) => [ + ...validRules, + getInvalidAssertion({ + query: queryName, + matcher: '.toBeTruthy()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeDefined()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.toBeInTheDocument()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeFalsy()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + getInvalidAssertion({ + query: queryName, + matcher: '.not.toBeNull()', + messageId: 'wrongPresenceQuery', + shouldUseScreen: true, + }), + ], + [] + ), + { + code: 'expect(screen.getAllByText("button")[1]).not.toBeInTheDocument()', + errors: [{ messageId: 'wrongAbsenceQuery', line: 1, column: 15 }], + }, { code: 'expect(screen.queryAllByText("button")[1]).toBeInTheDocument()', - errors: [{ messageId: 'presenceQuery' }], + errors: [{ messageId: 'wrongPresenceQuery', line: 1, column: 15 }], + }, + { + code: ` + // case: asserting presence incorrectly with custom queryBy* query + expect(queryByCustomQuery("button")).toBeInTheDocument() + `, + errors: [{ messageId: 'wrongPresenceQuery', line: 3, column: 16 }], + }, + { + code: ` + // case: asserting absence incorrectly with custom getBy* query + expect(getByCustomQuery("button")).not.toBeInTheDocument() + `, + errors: [{ messageId: 'wrongAbsenceQuery', line: 3, column: 16 }], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: asserting presence incorrectly importing custom module + import 'test-utils' + expect(queryByRole("button")).toBeInTheDocument() + `, + errors: [{ line: 4, column: 14, messageId: 'wrongPresenceQuery' }], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // case: asserting absence incorrectly importing custom module + import 'test-utils' + expect(getByRole("button")).not.toBeInTheDocument() + `, + errors: [{ line: 4, column: 14, messageId: 'wrongAbsenceQuery' }], }, ], }); diff --git a/tests/lib/rules/prefer-screen-queries.test.ts b/tests/lib/rules/prefer-screen-queries.test.ts index b9124570..23bb8135 100644 --- a/tests/lib/rules/prefer-screen-queries.test.ts +++ b/tests/lib/rules/prefer-screen-queries.test.ts @@ -1,15 +1,27 @@ import { createRuleTester } from '../test-utils'; import rule, { RULE_NAME } from '../../../lib/rules/prefer-screen-queries'; -import { ALL_QUERIES_COMBINATIONS } from '../../../lib/utils'; +import { + ALL_QUERIES_COMBINATIONS, + ALL_QUERIES_VARIANTS, + combineQueries, +} from '../../../lib/utils'; const ruleTester = createRuleTester(); +const CUSTOM_QUERY_COMBINATIONS = combineQueries(ALL_QUERIES_VARIANTS, [ + 'ByIcon', +]); +const ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS = [ + ...ALL_QUERIES_COMBINATIONS, + ...CUSTOM_QUERY_COMBINATIONS, +]; + ruleTester.run(RULE_NAME, rule, { valid: [ { code: `const baz = () => 'foo'`, }, - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: `screen.${queryMethod}()`, })), { @@ -18,19 +30,19 @@ ruleTester.run(RULE_NAME, rule, { { code: `component.otherFunctionShouldNotThrow()`, }, - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: `within(component).${queryMethod}()`, })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: `within(screen.${queryMethod}()).${queryMethod}()`, })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: ` const { ${queryMethod} } = within(screen.getByText('foo')) ${queryMethod}(baz) `, })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map((queryMethod) => ({ code: ` const myWithinVariable = within(foo) myWithinVariable.${queryMethod}('baz') @@ -84,165 +96,306 @@ ruleTester.run(RULE_NAME, rule, { utils.unmount(); `, }, - ...ALL_QUERIES_COMBINATIONS.map((queryMethod: string) => ({ - code: ` + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod: string) => ({ + code: ` const { ${queryMethod} } = render(baz, { baseElement: treeA }) expect(${queryMethod}(baz)).toBeDefined() `, - })), - ...ALL_QUERIES_COMBINATIONS.map((queryMethod: string) => ({ - code: ` + }) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod: string) => ({ + code: ` const { ${queryMethod}: aliasMethod } = render(baz, { baseElement: treeA }) expect(aliasMethod(baz)).toBeDefined() `, - })), - ...ALL_QUERIES_COMBINATIONS.map((queryMethod: string) => ({ - code: ` + }) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod: string) => ({ + code: ` const { ${queryMethod} } = render(baz, { container: treeA }) expect(${queryMethod}(baz)).toBeDefined() `, - })), - ...ALL_QUERIES_COMBINATIONS.map((queryMethod: string) => ({ - code: ` + }) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod: string) => ({ + code: ` const { ${queryMethod}: aliasMethod } = render(baz, { container: treeA }) expect(aliasMethod(baz)).toBeDefined() `, - })), - ...ALL_QUERIES_COMBINATIONS.map((queryMethod: string) => ({ - code: ` + }) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod: string) => ({ + code: ` const { ${queryMethod} } = render(baz, { baseElement: treeB, container: treeA }) expect(${queryMethod}(baz)).toBeDefined() `, - })), - ...ALL_QUERIES_COMBINATIONS.map((queryMethod: string) => ({ - code: ` + }) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod: string) => ({ + code: ` const { ${queryMethod}: aliasMethod } = render(baz, { baseElement: treeB, container: treeA }) expect(aliasMethod(baz)).toBeDefined() `, - })), - ...ALL_QUERIES_COMBINATIONS.map((queryMethod: string) => ({ - code: ` + }) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod: string) => ({ + code: ` render(foo, { baseElement: treeA }).${queryMethod}() `, + }) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map((queryMethod) => ({ + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render as testUtilRender } from 'test-utils' + import { render } from 'somewhere-else' + const { ${queryMethod} } = render(foo) + ${queryMethod}()`, + })), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map((queryMethod) => ({ + settings: { + 'testing-library/custom-renders': ['customRender'], + }, + code: ` + import { anotherRender } from 'whatever' + const { ${queryMethod} } = anotherRender(foo) + ${queryMethod}()`, })), ], invalid: [ - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: ` + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + code: ` const { ${queryMethod} } = render(foo) ${queryMethod}()`, - errors: [ - { - messageId: 'preferScreenQueries', - data: { - name: queryMethod, - }, - }, - ], - })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: `render().${queryMethod}()`, - errors: [ - { - messageId: 'preferScreenQueries', - data: { - name: queryMethod, - }, - }, - ], - })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: `render(foo, { hydrate: true }).${queryMethod}()`, - errors: [ - { - messageId: 'preferScreenQueries', - data: { - name: queryMethod, - }, - }, - ], - })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: `component.${queryMethod}()`, - errors: [ - { - messageId: 'preferScreenQueries', - data: { - name: queryMethod, + errors: [ + { + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render } from 'test-utils' + const { ${queryMethod} } = render(foo) + ${queryMethod}()`, + errors: [ + { + line: 4, + column: 9, + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + settings: { + 'testing-library/custom-renders': ['customRender'], }, - }, - ], - })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: ` + code: ` + import { customRender } from 'whatever' + const { ${queryMethod} } = customRender(foo) + ${queryMethod}()`, + errors: [ + { + line: 4, + column: 9, + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render as testingLibraryRender} from '@testing-library/react' + const { ${queryMethod} } = testingLibraryRender(foo) + ${queryMethod}()`, + errors: [ + { + line: 4, + column: 9, + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render } from 'test-utils' + const { ${queryMethod} } = render(foo) + ${queryMethod}()`, + errors: [ + { + line: 4, + column: 9, + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + code: `render().${queryMethod}()`, + errors: [ + { + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + code: `render(foo, { hydrate: true }).${queryMethod}()`, + errors: [ + { + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + code: `component.${queryMethod}()`, + errors: [ + { + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + code: ` const { ${queryMethod} } = render() ${queryMethod}(baz) `, - errors: [ - { - messageId: 'preferScreenQueries', - data: { - name: queryMethod, - }, - }, - ], - })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: ` + errors: [ + { + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + code: ` const myRenderVariable = render() myRenderVariable.${queryMethod}(baz) `, - errors: [ - { - messageId: 'preferScreenQueries', - data: { - name: queryMethod, - }, - }, - ], - })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: ` + errors: [ + { + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + code: ` const [myVariable] = render() myVariable.${queryMethod}(baz) `, - errors: [ - { - messageId: 'preferScreenQueries', - data: { - name: queryMethod, - }, - }, - ], - })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: ` + errors: [ + { + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + code: ` const { ${queryMethod} } = render(baz, { hydrate: true }) ${queryMethod}(baz) `, - errors: [ - { - messageId: 'preferScreenQueries', - data: { - name: queryMethod, - }, - }, - ], - })), - ...ALL_QUERIES_COMBINATIONS.map(queryMethod => ({ - code: ` + errors: [ + { + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), + ...ALL_BUILTIN_AND_CUSTOM_QUERIES_COMBINATIONS.map( + (queryMethod) => + ({ + code: ` const [myVariable] = within() myVariable.${queryMethod}(baz) `, - errors: [ - { - messageId: 'preferScreenQueries', - data: { - name: queryMethod, - }, - }, - ], - })), + errors: [ + { + messageId: 'preferScreenQueries', + data: { + name: queryMethod, + }, + }, + ], + } as const) + ), ], }); diff --git a/tests/lib/rules/prefer-user-event.test.ts b/tests/lib/rules/prefer-user-event.test.ts new file mode 100644 index 00000000..f4b23cfa --- /dev/null +++ b/tests/lib/rules/prefer-user-event.test.ts @@ -0,0 +1,419 @@ +import { + InvalidTestCase, + ValidTestCase, +} from '@typescript-eslint/experimental-utils/dist/ts-eslint'; +import { createRuleTester } from '../test-utils'; +import { LIBRARY_MODULES } from '../../../lib/utils'; +import rule, { + MAPPING_TO_USER_EVENT, + MessageIds, + Options, + RULE_NAME, + UserEventMethods, +} from '../../../lib/rules/prefer-user-event'; + +function createScenarioWithImport< + T extends ValidTestCase | InvalidTestCase +>(callback: (libraryModule: string, fireEventMethod: string) => T) { + return LIBRARY_MODULES.reduce( + (acc: Array, libraryModule) => + acc.concat( + Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod) => + callback(libraryModule, fireEventMethod) + ) + ), + [] + ); +} + +const ruleTester = createRuleTester(); + +function formatUserEventMethodsMessage(fireEventMethod: string): string { + const userEventMethods = MAPPING_TO_USER_EVENT[fireEventMethod].map( + (methodName) => `userEvent.${methodName}` + ); + let joinedList = ''; + + for (let i = 0; i < userEventMethods.length; i++) { + const item = userEventMethods[i]; + if (i === 0) { + joinedList += item; + } else if (i + 1 === userEventMethods.length) { + joinedList += `, or ${item}`; + } else { + joinedList += `, ${item}`; + } + } + + return joinedList; +} + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: ` + import { screen } from '@testing-library/user-event' + const element = screen.getByText(foo) + `, + }, + { + code: ` + const utils = render(baz) + const element = utils.getByText(foo) + `, + }, + ...UserEventMethods.map((userEventMethod) => ({ + code: ` + import userEvent from '@testing-library/user-event' + const node = document.createElement(elementType) + userEvent.${userEventMethod}(foo) + `, + })), + ...createScenarioWithImport>( + (libraryModule: string, fireEventMethod: string) => ({ + code: ` + import { fireEvent } from '${libraryModule}' + const node = document.createElement(elementType) + fireEvent.${fireEventMethod}(foo) + `, + options: [{ allowedMethods: [fireEventMethod] }], + }) + ), + ...createScenarioWithImport>( + (libraryModule: string, fireEventMethod: string) => ({ + code: ` + import { fireEvent as fireEventAliased } from '${libraryModule}' + const node = document.createElement(elementType) + fireEventAliased.${fireEventMethod}(foo) + `, + options: [{ allowedMethods: [fireEventMethod] }], + }) + ), + ...createScenarioWithImport>( + (libraryModule: string, fireEventMethod: string) => ({ + code: ` + import * as dom from '${libraryModule}' + dom.fireEvent.${fireEventMethod}(foo) + `, + options: [{ allowedMethods: [fireEventMethod] }], + }) + ), + ...LIBRARY_MODULES.map((libraryModule) => ({ + // imported fireEvent and not used, + code: ` + import { fireEvent } from '${libraryModule}' + import * as foo from 'someModule' + foo.baz() + `, + })), + ...LIBRARY_MODULES.map((libraryModule) => ({ + // imported dom, but not using fireEvent + code: ` + import * as dom from '${libraryModule}' + const button = dom.screen.getByRole('button') + const foo = dom.screen.container.querySelector('baz') + `, + })), + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: ` + import { fireEvent as aliasedFireEvent } from '${libraryModule}' + function fireEvent() { + console.log('foo') + } + fireEvent() + `, + })), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { screen } from 'test-utils' + const element = screen.getByText(foo) + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { render } from 'test-utils' + const utils = render(baz) + const element = utils.getByText(foo) + `, + }, + ...UserEventMethods.map((userEventMethod) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import userEvent from 'test-utils' + const node = document.createElement(elementType) + userEvent.${userEventMethod}(foo) + `, + })), + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod: string) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + // fireEvent method used but not imported from TL related module + // (aggressive reporting opted out) + import { fireEvent } from 'somewhere-else' + fireEvent.${fireEventMethod}(foo) + `, + })), + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod: string) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent } from 'test-utils' + const node = document.createElement(elementType) + fireEvent.${fireEventMethod}(foo) + `, + options: [{ allowedMethods: [fireEventMethod] }], + })), + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod: string) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent as fireEventAliased } from 'test-utils' + const node = document.createElement(elementType) + fireEventAliased.${fireEventMethod}(foo) + `, + options: [{ allowedMethods: [fireEventMethod] }], + })), + ...Object.keys(MAPPING_TO_USER_EVENT).map((fireEventMethod: string) => ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import * as dom from 'test-utils' + dom.fireEvent.${fireEventMethod}(foo) + `, + options: [{ allowedMethods: [fireEventMethod] }], + })), + // edge case for coverage: + // valid use case without call expression + // so there is no innermost function scope found + ` + import { fireEvent } from '@testing-library/react'; + test('edge case for no innermost function scope', () => { + const click = fireEvent.click + }) + `, + ], + invalid: [ + ...createScenarioWithImport>( + (libraryModule: string, fireEventMethod: string) => ({ + code: ` + import { fireEvent } from '${libraryModule}' + const node = document.createElement(elementType) + fireEvent.${fireEventMethod}(foo) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 4, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage(fireEventMethod), + fireEventMethod: fireEventMethod, + }, + }, + ], + }) + ), + ...createScenarioWithImport>( + (libraryModule: string, fireEventMethod: string) => ({ + code: ` + import * as dom from '${libraryModule}' + dom.fireEvent.${fireEventMethod}(foo) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 3, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage(fireEventMethod), + fireEventMethod: fireEventMethod, + }, + }, + ], + }) + ), + ...createScenarioWithImport>( + (libraryModule: string, fireEventMethod: string) => ({ + code: ` + const { fireEvent } = require('${libraryModule}') + fireEvent.${fireEventMethod}(foo) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 3, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage(fireEventMethod), + fireEventMethod: fireEventMethod, + }, + }, + ], + }) + ), + ...createScenarioWithImport>( + (libraryModule: string, fireEventMethod: string) => ({ + code: ` + const rtl = require('${libraryModule}') + rtl.fireEvent.${fireEventMethod}(foo) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 3, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage(fireEventMethod), + fireEventMethod: fireEventMethod, + }, + }, + ], + }) + ), + ...Object.keys(MAPPING_TO_USER_EVENT).map( + (fireEventMethod: string) => + ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import * as dom from 'test-utils' + dom.fireEvent.${fireEventMethod}(foo) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 3, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage( + fireEventMethod + ), + fireEventMethod: fireEventMethod, + }, + }, + ], + } as const) + ), + ...Object.keys(MAPPING_TO_USER_EVENT).map( + (fireEventMethod: string) => + ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent } from 'test-utils' + fireEvent.${fireEventMethod}(foo) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 3, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage( + fireEventMethod + ), + fireEventMethod: fireEventMethod, + }, + }, + ], + } as const) + ), + ...Object.keys(MAPPING_TO_USER_EVENT).map( + (fireEventMethod: string) => + ({ + code: ` + // same as previous group of test cases but without custom module set + // (aggressive reporting) + import { fireEvent } from 'test-utils' + fireEvent.${fireEventMethod}(foo) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 5, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage( + fireEventMethod + ), + fireEventMethod: fireEventMethod, + }, + }, + ], + } as const) + ), + ...Object.keys(MAPPING_TO_USER_EVENT).map( + (fireEventMethod: string) => + ({ + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: ` + import { fireEvent as fireEventAliased } from 'test-utils' + fireEventAliased.${fireEventMethod}(foo) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 3, + column: 9, + data: { + userEventMethods: formatUserEventMethodsMessage( + fireEventMethod + ), + fireEventMethod: fireEventMethod, + }, + }, + ], + } as const) + ), + { + code: ` // simple test to check error in detail + import { fireEvent } from '@testing-library/react' + + fireEvent.click(element) + fireEvent.mouseOut(element) + `, + errors: [ + { + messageId: 'preferUserEvent', + line: 4, + endLine: 4, + column: 7, + endColumn: 22, + data: { + userEventMethods: + 'userEvent.click, userEvent.type, userEvent.selectOptions, or userEvent.deselectOptions', + fireEventMethod: 'click', + }, + }, + { + messageId: 'preferUserEvent', + line: 5, + endLine: 5, + column: 7, + endColumn: 25, + data: { + userEventMethods: 'userEvent.unhover', + fireEventMethod: 'mouseOut', + }, + }, + ], + }, + ], +}); diff --git a/tests/lib/rules/prefer-wait-for.test.ts b/tests/lib/rules/prefer-wait-for.test.ts index fa0c4c6e..410934b1 100644 --- a/tests/lib/rules/prefer-wait-for.test.ts +++ b/tests/lib/rules/prefer-wait-for.test.ts @@ -1,33 +1,134 @@ import { createRuleTester } from '../test-utils'; +import { LIBRARY_MODULES } from '../../../lib/utils'; import rule, { RULE_NAME } from '../../../lib/rules/prefer-wait-for'; const ruleTester = createRuleTester(); ruleTester.run(RULE_NAME, rule, { valid: [ + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: `import { waitFor, render } from '${libraryModule}'; + + async () => { + await waitFor(() => {}); + }`, + })), + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: `const { waitFor, render } = require('${libraryModule}'); + + async () => { + await waitFor(() => {}); + }`, + })), { - code: `import { waitFor, render } from '@testing-library/foo'; + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { waitFor, render } from 'test-utils'; async () => { await waitFor(() => {}); }`, }, { - code: `import { waitForElementToBeRemoved, render } from '@testing-library/foo'; + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { waitFor, render } = require('test-utils'); + + async () => { + await waitFor(() => {}); + }`, + }, + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: `import { waitForElementToBeRemoved, render } from '${libraryModule}'; + + async () => { + await waitForElementToBeRemoved(() => {}); + }`, + })), + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: `const { waitForElementToBeRemoved, render } = require('${libraryModule}'); + + async () => { + await waitForElementToBeRemoved(() => {}); + }`, + })), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { waitForElementToBeRemoved, render } from 'test-utils'; + + async () => { + await waitForElementToBeRemoved(() => {}); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { waitForElementToBeRemoved, render } = require('test-utils'); async () => { await waitForElementToBeRemoved(() => {}); }`, }, + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: `import * as testingLibrary from '${libraryModule}'; + + async () => { + await testingLibrary.waitForElementToBeRemoved(() => {}); + }`, + })), + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: `const testingLibrary = require('${libraryModule}'); + + async () => { + await testingLibrary.waitForElementToBeRemoved(() => {}); + }`, + })), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import * as testingLibrary from 'test-utils'; + + async () => { + await testingLibrary.waitForElementToBeRemoved(() => {}); + }`, + }, { - code: `import * as testingLibrary from '@testing-library/foo'; + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const testingLibrary = require('test-utils'); async () => { await testingLibrary.waitForElementToBeRemoved(() => {}); }`, }, + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: `import { render } from '${libraryModule}'; + import { waitForSomethingElse } from 'other-module'; + + async () => { + await waitForSomethingElse(() => {}); + }`, + })), + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: `const { render } = require('${libraryModule}'); + const { waitForSomethingElse } = require('other-module'); + + async () => { + await waitForSomethingElse(() => {}); + }`, + })), { - code: `import { render } from '@testing-library/foo'; + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { render } from 'test-utils'; import { waitForSomethingElse } from 'other-module'; async () => { @@ -35,8 +136,46 @@ ruleTester.run(RULE_NAME, rule, { }`, }, { - code: `import * as testingLibrary from '@testing-library/foo'; - + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { render } = require('test-utils'); + const { waitForSomethingElse } = require('other-module'); + + async () => { + await waitForSomethingElse(() => {}); + }`, + }, + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: `import * as testingLibrary from '${libraryModule}'; + + async () => { + await testingLibrary.waitFor(() => {}, { timeout: 500 }); + }`, + })), + ...LIBRARY_MODULES.map((libraryModule) => ({ + code: `const testingLibrary = require('${libraryModule}'); + + async () => { + await testingLibrary.waitFor(() => {}, { timeout: 500 }); + }`, + })), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import * as testingLibrary from 'test-utils'; + + async () => { + await testingLibrary.waitFor(() => {}, { timeout: 500 }); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const testingLibrary = require('test-utils'); + async () => { await testingLibrary.waitFor(() => {}, { timeout: 500 }); }`, @@ -48,6 +187,13 @@ ruleTester.run(RULE_NAME, rule, { await wait(); }`, }, + { + code: `const { wait } = require('imNoTestingLibrary'); + + async () => { + await wait(); + }`, + }, { code: `import * as foo from 'imNoTestingLibrary'; @@ -56,13 +202,52 @@ ruleTester.run(RULE_NAME, rule, { }`, }, { - code: ` + code: `const foo = require('imNoTestingLibrary'); + + async () => { + await foo.wait(); + }`, + }, + { + code: `import * as foo from 'imNoTestingLibrary'; cy.wait(); `, }, { - // https://github.com/testing-library/eslint-plugin-testing-library/issues/145 + code: `const foo = require('imNoTestingLibrary'); + cy.wait(); + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, code: ` + // case: aggressive reporting disabled - method named same as invalid method + // but not coming from Testing Library is valid + import { wait as testingLibraryWait } from 'test-utils' + import { wait } from 'somewhere-else' + + async () => { + await wait(); + } + `, + }, + { + // https://github.com/testing-library/eslint-plugin-testing-library/issues/145 + code: `import * as foo from 'imNoTestingLibrary'; + async function wait(): Promise { + // doesn't matter + } + + function callsWait(): void { + await wait(); + } + `, + }, + { + // https://github.com/testing-library/eslint-plugin-testing-library/issues/145 + code: `const foo = require('imNoTestingLibrary'); async function wait(): Promise { // doesn't matter } @@ -75,9 +260,66 @@ ruleTester.run(RULE_NAME, rule, { ], invalid: [ + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { wait, render } from '${libraryModule}'; + + async () => { + await wait(); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { render,waitFor } from '${libraryModule}'; + + async () => { + await waitFor(() => {}); + }`, + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { wait, render } = require('${libraryModule}'); + + async () => { + await wait(); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { render,waitFor } = require('${libraryModule}'); + + async () => { + await waitFor(() => {}); + }`, + } as const) + ), { - code: `import { wait, render } from '@testing-library/foo'; - + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { wait, render } from 'test-utils'; + async () => { await wait(); }`, @@ -93,16 +335,91 @@ ruleTester.run(RULE_NAME, rule, { column: 15, }, ], - output: `import { render,waitFor } from '@testing-library/foo'; - + output: `import { render,waitFor } from 'test-utils'; + + async () => { + await waitFor(() => {}); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { wait, render } = require('test-utils'); + + async () => { + await wait(); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { render,waitFor } = require('test-utils'); + async () => { await waitFor(() => {}); }`, }, // namespaced wait should be fixed but not its import + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import * as testingLibrary from '${libraryModule}'; + + async () => { + await testingLibrary.wait(); + }`, + errors: [ + { + messageId: 'preferWaitForMethod', + line: 4, + column: 30, + }, + ], + output: `import * as testingLibrary from '${libraryModule}'; + + async () => { + await testingLibrary.waitFor(() => {}); + }`, + } as const) + ), + // namespaced wait should be fixed but not its import + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const testingLibrary = require('${libraryModule}'); + + async () => { + await testingLibrary.wait(); + }`, + errors: [ + { + messageId: 'preferWaitForMethod', + line: 4, + column: 30, + }, + ], + output: `const testingLibrary = require('${libraryModule}'); + + async () => { + await testingLibrary.waitFor(() => {}); + }`, + } as const) + ), { - code: `import * as testingLibrary from '@testing-library/foo'; - + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import * as testingLibrary from 'test-utils'; + async () => { await testingLibrary.wait(); }`, @@ -113,16 +430,108 @@ ruleTester.run(RULE_NAME, rule, { column: 30, }, ], - output: `import * as testingLibrary from '@testing-library/foo'; - + output: `import * as testingLibrary from 'test-utils'; + + async () => { + await testingLibrary.waitFor(() => {}); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const testingLibrary = require('test-utils'); + + async () => { + await testingLibrary.wait(); + }`, + errors: [ + { + messageId: 'preferWaitForMethod', + line: 4, + column: 30, + }, + ], + output: `const testingLibrary = require('test-utils'); + async () => { await testingLibrary.waitFor(() => {}); }`, }, // namespaced waitForDomChange should be fixed but not its import + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import * as testingLibrary from '${libraryModule}'; + + async () => { + await testingLibrary.waitForDomChange({ timeout: 500 }); + }`, + errors: [ + { + messageId: 'preferWaitForMethod', + line: 4, + column: 30, + }, + ], + output: `import * as testingLibrary from '${libraryModule}'; + + async () => { + await testingLibrary.waitFor(() => {}, { timeout: 500 }); + }`, + } as const) + ), + // namespaced waitForDomChange should be fixed but not its import + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const testingLibrary = require('${libraryModule}'); + + async () => { + await testingLibrary.waitForDomChange({ timeout: 500 }); + }`, + errors: [ + { + messageId: 'preferWaitForMethod', + line: 4, + column: 30, + }, + ], + output: `const testingLibrary = require('${libraryModule}'); + + async () => { + await testingLibrary.waitFor(() => {}, { timeout: 500 }); + }`, + } as const) + ), { - code: `import * as testingLibrary from '@testing-library/foo'; - + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import * as testingLibrary from 'test-utils'; + + async () => { + await testingLibrary.waitForDomChange({ timeout: 500 }); + }`, + errors: [ + { + messageId: 'preferWaitForMethod', + line: 4, + column: 30, + }, + ], + output: `import * as testingLibrary from 'test-utils'; + + async () => { + await testingLibrary.waitFor(() => {}, { timeout: 500 }); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const testingLibrary = require('test-utils'); + async () => { await testingLibrary.waitForDomChange({ timeout: 500 }); }`, @@ -133,44 +542,1109 @@ ruleTester.run(RULE_NAME, rule, { column: 30, }, ], - output: `import * as testingLibrary from '@testing-library/foo'; + output: `const testingLibrary = require('test-utils'); + + async () => { + await testingLibrary.waitFor(() => {}, { timeout: 500 }); + }`, + }, + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { render, wait } from '${libraryModule}' + + async () => { + await wait(() => {}); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { render,waitFor } from '${libraryModule}'; + + async () => { + await waitFor(() => {}); + }`, + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { render, wait } = require('${libraryModule}'); + + async () => { + await wait(() => {}); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { render,waitFor } = require('${libraryModule}'); + + async () => { + await waitFor(() => {}); + }`, + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { render, wait } from 'test-utils' + + async () => { + await wait(() => {}); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { render,waitFor } from 'test-utils'; + + async () => { + await waitFor(() => {}); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { render, wait } = require('test-utils'); + + async () => { + await wait(() => {}); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { render,waitFor } = require('test-utils'); + + async () => { + await waitFor(() => {}); + }`, + }, + // this import doesn't have trailing semicolon but fixer adds it + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { render, wait, screen } from "${libraryModule}"; + + async () => { + await wait(function cb() { + doSomething(); + }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { render,screen,waitFor } from '${libraryModule}'; + + async () => { + await waitFor(function cb() { + doSomething(); + }); + }`, + } as const) + ), + // this import doesn't have trailing semicolon but fixer adds it + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { render, wait, screen } from "${libraryModule}"; + + async () => { + await wait(function cb() { + doSomething(); + }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { render,screen,waitFor } from '${libraryModule}'; + + async () => { + await waitFor(function cb() { + doSomething(); + }); + }`, + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { render, wait, screen } from "test-utils"; + + async () => { + await wait(function cb() { + doSomething(); + }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { render,screen,waitFor } from 'test-utils'; + + async () => { + await waitFor(function cb() { + doSomething(); + }); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { render, wait, screen } = require('test-utils'); + + async () => { + await wait(function cb() { + doSomething(); + }); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { render,screen,waitFor } = require('test-utils'); + + async () => { + await waitFor(function cb() { + doSomething(); + }); + }`, + }, + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { render, waitForElement, screen } from '${libraryModule}' + + async () => { + await waitForElement(() => {}); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { render,screen,waitFor } from '${libraryModule}'; + + async () => { + await waitFor(() => {}); + }`, + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { render, waitForElement, screen } = require('${libraryModule}'); + + async () => { + await waitForElement(() => {}); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { render,screen,waitFor } = require('${libraryModule}'); + + async () => { + await waitFor(() => {}); + }`, + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { render, waitForElement, screen } from 'test-utils' + + async () => { + await waitForElement(() => {}); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { render,screen,waitFor } from 'test-utils'; + + async () => { + await waitFor(() => {}); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { render, waitForElement, screen } = require('test-utils'); + + async () => { + await waitForElement(() => {}); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { render,screen,waitFor } = require('test-utils'); + + async () => { + await waitFor(() => {}); + }`, + }, + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { waitForElement } from '${libraryModule}'; + + async () => { + await waitForElement(function cb() { + doSomething(); + }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { waitFor } from '${libraryModule}'; + + async () => { + await waitFor(function cb() { + doSomething(); + }); + }`, + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { waitForElement } = require('${libraryModule}'); + + async () => { + await waitForElement(function cb() { + doSomething(); + }); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { waitFor } = require('${libraryModule}'); + + async () => { + await waitFor(function cb() { + doSomething(); + }); + }`, + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { waitForElement } from 'test-utils'; + + async () => { + await waitForElement(function cb() { + doSomething(); + }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { waitFor } from 'test-utils'; + + async () => { + await waitFor(function cb() { + doSomething(); + }); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { waitForElement } = require('test-utils'); + + async () => { + await waitForElement(function cb() { + doSomething(); + }); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { waitFor } = require('test-utils'); + + async () => { + await waitFor(function cb() { + doSomething(); + }); + }`, + }, + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { waitForDomChange } from '${libraryModule}'; + + async () => { + await waitForDomChange(); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { waitFor } from '${libraryModule}'; + + async () => { + await waitFor(() => {}); + }`, + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { waitForDomChange } = require('${libraryModule}'); + + async () => { + await waitForDomChange(); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { waitFor } = require('${libraryModule}'); + + async () => { + await waitFor(() => {}); + }`, + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { waitForDomChange } from 'test-utils'; + + async () => { + await waitForDomChange(); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { waitFor } from 'test-utils'; + + async () => { + await waitFor(() => {}); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { waitForDomChange } = require('test-utils'); + + async () => { + await waitForDomChange(); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { waitFor } = require('test-utils'); + + async () => { + await waitFor(() => {}); + }`, + }, + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { waitForDomChange } from '${libraryModule}'; + + async () => { + await waitForDomChange(mutationObserverOptions); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { waitFor } from '${libraryModule}'; + + async () => { + await waitFor(() => {}, mutationObserverOptions); + }`, + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { waitForDomChange } = require('${libraryModule}'); + + async () => { + await waitForDomChange(mutationObserverOptions); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { waitFor } = require('${libraryModule}'); + + async () => { + await waitFor(() => {}, mutationObserverOptions); + }`, + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { waitForDomChange } from 'test-utils'; + + async () => { + await waitForDomChange(mutationObserverOptions); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { waitFor } from 'test-utils'; + + async () => { + await waitFor(() => {}, mutationObserverOptions); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { waitForDomChange } = require('test-utils'); + + async () => { + await waitForDomChange(mutationObserverOptions); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { waitFor } = require('test-utils'); + + async () => { + await waitFor(() => {}, mutationObserverOptions); + }`, + }, + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { waitForDomChange } from '${libraryModule}'; + + async () => { + await waitForDomChange({ timeout: 5000 }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { waitFor } from '${libraryModule}'; + + async () => { + await waitFor(() => {}, { timeout: 5000 }); + }`, + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { waitForDomChange } = require('${libraryModule}'); + + async () => { + await waitForDomChange({ timeout: 5000 }); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { waitFor } = require('${libraryModule}'); + + async () => { + await waitFor(() => {}, { timeout: 5000 }); + }`, + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { waitForDomChange } from 'test-utils'; + + async () => { + await waitForDomChange({ timeout: 5000 }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { waitFor } from 'test-utils'; + + async () => { + await waitFor(() => {}, { timeout: 5000 }); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { waitForDomChange } = require('test-utils'); + + async () => { + await waitForDomChange({ timeout: 5000 }); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { waitFor } = require('test-utils'); + + async () => { + await waitFor(() => {}, { timeout: 5000 }); + }`, + }, + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { waitForDomChange, wait, waitForElement } from '${libraryModule}'; + import userEvent from '@testing-library/user-event'; + + async () => { + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 5, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 6, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 7, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 8, + column: 15, + }, + ], + output: `import { waitFor } from '${libraryModule}'; + import userEvent from '@testing-library/user-event'; + + async () => { + await waitFor(() => {}, { timeout: 5000 }); + await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); + }`, + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { waitForDomChange, wait, waitForElement } = require('${libraryModule}'); + const userEvent = require('@testing-library/user-event'); + + async () => { + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 5, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 6, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 7, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 8, + column: 15, + }, + ], + output: `const { waitFor } = require('${libraryModule}'); + const userEvent = require('@testing-library/user-event'); + + async () => { + await waitFor(() => {}, { timeout: 5000 }); + await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); + }`, + } as const) + ), + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { waitForDomChange, wait, waitForElement } from 'test-utils'; + import userEvent from '@testing-library/user-event'; + + async () => { + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 5, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 6, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 7, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 8, + column: 15, + }, + ], + output: `import { waitFor } from 'test-utils'; + import userEvent from '@testing-library/user-event'; + + async () => { + await waitFor(() => {}, { timeout: 5000 }); + await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { waitForDomChange, wait, waitForElement } = require('test-utils'); + const userEvent = require('@testing-library/user-event'); + + async () => { + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 5, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 6, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 7, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 8, + column: 15, + }, + ], + output: `const { waitFor } = require('test-utils'); + const userEvent = require('@testing-library/user-event'); + + async () => { + await waitFor(() => {}, { timeout: 5000 }); + await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); + }`, + }, + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { render, waitForDomChange, wait, waitForElement } from '${libraryModule}'; + + async () => { + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 5, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 6, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 7, + column: 15, + }, + ], + output: `import { render,waitFor } from '${libraryModule}'; async () => { - await testingLibrary.waitFor(() => {}, { timeout: 500 }); + await waitFor(() => {}, { timeout: 5000 }); + await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); }`, - }, - { - // this import doesn't have trailing semicolon but fixer adds it - code: `import { render, wait } from '@testing-library/foo' + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { render, waitForDomChange, wait, waitForElement } = require('${libraryModule}'); async () => { - await wait(() => {}); + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); }`, - errors: [ - { - messageId: 'preferWaitForImport', - line: 1, - column: 1, - }, - { - messageId: 'preferWaitForMethod', - line: 4, - column: 15, - }, - ], - output: `import { render,waitFor } from '@testing-library/foo'; + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 5, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 6, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 7, + column: 15, + }, + ], + output: `const { render,waitFor } = require('${libraryModule}'); async () => { + await waitFor(() => {}, { timeout: 5000 }); await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); }`, - }, + } as const) + ), { - code: `import { render, wait, screen } from "@testing-library/foo"; + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { render, waitForDomChange, wait, waitForElement } from 'test-utils'; async () => { - await wait(function cb() { - doSomething(); - }); + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); }`, errors: [ { @@ -183,142 +1657,180 @@ ruleTester.run(RULE_NAME, rule, { line: 4, column: 15, }, - ], - output: `import { render,screen,waitFor } from '@testing-library/foo'; - - async () => { - await waitFor(function cb() { - doSomething(); - }); - }`, - }, - { - code: `import { render, waitForElement, screen } from '@testing-library/foo' - - async () => { - await waitForElement(() => {}); - }`, - errors: [ { - messageId: 'preferWaitForImport', - line: 1, - column: 1, + messageId: 'preferWaitForMethod', + line: 5, + column: 15, }, { messageId: 'preferWaitForMethod', - line: 4, + line: 6, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 7, column: 15, }, ], - output: `import { render,screen,waitFor } from '@testing-library/foo'; + output: `import { render,waitFor } from 'test-utils'; async () => { + await waitFor(() => {}, { timeout: 5000 }); await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); }`, }, { - code: `import { waitForElement } from '@testing-library/foo'; + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { render, waitForDomChange, wait, waitForElement } = require('test-utils'); async () => { - await waitForElement(function cb() { - doSomething(); - }); + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); }`, errors: [ { - messageId: 'preferWaitForImport', + messageId: 'preferWaitForRequire', line: 1, - column: 1, + column: 7, }, { messageId: 'preferWaitForMethod', line: 4, column: 15, }, - ], - output: `import { waitFor } from '@testing-library/foo'; - - async () => { - await waitFor(function cb() { - doSomething(); - }); - }`, - }, - { - code: `import { waitForDomChange } from '@testing-library/foo'; - - async () => { - await waitForDomChange(); - }`, - errors: [ { - messageId: 'preferWaitForImport', - line: 1, - column: 1, + messageId: 'preferWaitForMethod', + line: 5, + column: 15, }, { messageId: 'preferWaitForMethod', - line: 4, + line: 6, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 7, column: 15, }, ], - output: `import { waitFor } from '@testing-library/foo'; + output: `const { render,waitFor } = require('test-utils'); async () => { + await waitFor(() => {}, { timeout: 5000 }); + await waitFor(() => {}); await waitFor(() => {}); + await waitFor(() => { doSomething() }); }`, }, - { - code: `import { waitForDomChange } from '@testing-library/foo'; + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { waitForDomChange, wait, render, waitForElement } from '${libraryModule}'; async () => { - await waitForDomChange(mutationObserverOptions); + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); }`, - errors: [ - { - messageId: 'preferWaitForImport', - line: 1, - column: 1, - }, - { - messageId: 'preferWaitForMethod', - line: 4, - column: 15, - }, - ], - output: `import { waitFor } from '@testing-library/foo'; + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 5, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 6, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 7, + column: 15, + }, + ], + output: `import { render,waitFor } from '${libraryModule}'; async () => { - await waitFor(() => {}, mutationObserverOptions); + await waitFor(() => {}, { timeout: 5000 }); + await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); }`, - }, - { - code: `import { waitForDomChange } from '@testing-library/foo'; + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { waitForDomChange, wait, render, waitForElement } = require('${libraryModule}'); async () => { await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); }`, - errors: [ - { - messageId: 'preferWaitForImport', - line: 1, - column: 1, - }, - { - messageId: 'preferWaitForMethod', - line: 4, - column: 15, - }, - ], - output: `import { waitFor } from '@testing-library/foo'; + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 5, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 6, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 7, + column: 15, + }, + ], + output: `const { render,waitFor } = require('${libraryModule}'); async () => { await waitFor(() => {}, { timeout: 5000 }); + await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); }`, - }, + } as const) + ), { - code: `import { waitForDomChange, wait, waitForElement } from '@testing-library/foo'; - import userEvent from '@testing-library/user-event'; + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { waitForDomChange, wait, render, waitForElement } from 'test-utils'; async () => { await waitForDomChange({ timeout: 5000 }); @@ -334,27 +1846,26 @@ ruleTester.run(RULE_NAME, rule, { }, { messageId: 'preferWaitForMethod', - line: 5, + line: 4, column: 15, }, { messageId: 'preferWaitForMethod', - line: 6, + line: 5, column: 15, }, { messageId: 'preferWaitForMethod', - line: 7, + line: 6, column: 15, }, { messageId: 'preferWaitForMethod', - line: 8, + line: 7, column: 15, }, ], - output: `import { waitFor } from '@testing-library/foo'; - import userEvent from '@testing-library/user-event'; + output: `import { render,waitFor } from 'test-utils'; async () => { await waitFor(() => {}, { timeout: 5000 }); @@ -364,7 +1875,10 @@ ruleTester.run(RULE_NAME, rule, { }`, }, { - code: `import { render, waitForDomChange, wait, waitForElement } from '@testing-library/foo'; + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { waitForDomChange, wait, render, waitForElement } = require('test-utils'); async () => { await waitForDomChange({ timeout: 5000 }); @@ -374,9 +1888,9 @@ ruleTester.run(RULE_NAME, rule, { }`, errors: [ { - messageId: 'preferWaitForImport', + messageId: 'preferWaitForRequire', line: 1, - column: 1, + column: 7, }, { messageId: 'preferWaitForMethod', @@ -399,7 +1913,7 @@ ruleTester.run(RULE_NAME, rule, { column: 15, }, ], - output: `import { render,waitFor } from '@testing-library/foo'; + output: `const { render,waitFor } = require('test-utils'); async () => { await waitFor(() => {}, { timeout: 5000 }); @@ -408,8 +1922,122 @@ ruleTester.run(RULE_NAME, rule, { await waitFor(() => { doSomething() }); }`, }, + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `import { + waitForDomChange, + wait, + render, + waitForElement, + } from '${libraryModule}'; + + async () => { + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 9, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 10, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 11, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 12, + column: 15, + }, + ], + output: `import { render,waitFor } from '${libraryModule}'; + + async () => { + await waitFor(() => {}, { timeout: 5000 }); + await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); + }`, + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + code: `const { + waitForDomChange, + wait, + render, + waitForElement, + } = require('${libraryModule}'); + + async () => { + await waitForDomChange({ timeout: 5000 }); + await waitForElement(); + await wait(); + await wait(() => { doSomething() }); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 9, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 10, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 11, + column: 15, + }, + { + messageId: 'preferWaitForMethod', + line: 12, + column: 15, + }, + ], + output: `const { render,waitFor } = require('${libraryModule}'); + + async () => { + await waitFor(() => {}, { timeout: 5000 }); + await waitFor(() => {}); + await waitFor(() => {}); + await waitFor(() => { doSomething() }); + }`, + } as const) + ), { - code: `import { waitForDomChange, wait, render, waitForElement } from '@testing-library/foo'; + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `import { + waitForDomChange, + wait, + render, + waitForElement, + } from 'test-utils'; async () => { await waitForDomChange({ timeout: 5000 }); @@ -425,26 +2053,26 @@ ruleTester.run(RULE_NAME, rule, { }, { messageId: 'preferWaitForMethod', - line: 4, + line: 9, column: 15, }, { messageId: 'preferWaitForMethod', - line: 5, + line: 10, column: 15, }, { messageId: 'preferWaitForMethod', - line: 6, + line: 11, column: 15, }, { messageId: 'preferWaitForMethod', - line: 7, + line: 12, column: 15, }, ], - output: `import { render,waitFor } from '@testing-library/foo'; + output: `import { render,waitFor } from 'test-utils'; async () => { await waitFor(() => {}, { timeout: 5000 }); @@ -454,12 +2082,15 @@ ruleTester.run(RULE_NAME, rule, { }`, }, { - code: `import { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `const { waitForDomChange, wait, render, waitForElement, - } from '@testing-library/foo'; + } = require('test-utils'); async () => { await waitForDomChange({ timeout: 5000 }); @@ -469,9 +2100,9 @@ ruleTester.run(RULE_NAME, rule, { }`, errors: [ { - messageId: 'preferWaitForImport', + messageId: 'preferWaitForRequire', line: 1, - column: 1, + column: 7, }, { messageId: 'preferWaitForMethod', @@ -494,7 +2125,7 @@ ruleTester.run(RULE_NAME, rule, { column: 15, }, ], - output: `import { render,waitFor } from '@testing-library/foo'; + output: `const { render,waitFor } = require('test-utils'); async () => { await waitFor(() => {}, { timeout: 5000 }); @@ -503,9 +2134,72 @@ ruleTester.run(RULE_NAME, rule, { await waitFor(() => { doSomething() }); }`, }, + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + // if already importing waitFor then it's not imported twice + code: `import { wait, waitFor, render } from '${libraryModule}'; + + async () => { + await wait(); + await waitFor(someCallback); + }`, + errors: [ + { + messageId: 'preferWaitForImport', + line: 1, + column: 1, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `import { render,waitFor } from '${libraryModule}'; + + async () => { + await waitFor(() => {}); + await waitFor(someCallback); + }`, + } as const) + ), + ...LIBRARY_MODULES.map( + (libraryModule) => + ({ + // if already importing waitFor then it's not imported twice + code: `const { wait, waitFor, render } = require('${libraryModule}'); + + async () => { + await wait(); + await waitFor(someCallback); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { render,waitFor } = require('${libraryModule}'); + + async () => { + await waitFor(() => {}); + await waitFor(someCallback); + }`, + } as const) + ), { + settings: { + 'testing-library/utils-module': 'test-utils', + }, // if already importing waitFor then it's not imported twice - code: `import { wait, waitFor, render } from '@testing-library/foo'; + code: `import { wait, waitFor, render } from 'test-utils'; async () => { await wait(); @@ -523,7 +2217,37 @@ ruleTester.run(RULE_NAME, rule, { column: 15, }, ], - output: `import { render,waitFor } from '@testing-library/foo'; + output: `import { render,waitFor } from 'test-utils'; + + async () => { + await waitFor(() => {}); + await waitFor(someCallback); + }`, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + // if already importing waitFor then it's not imported twice + code: `const { wait, waitFor, render } = require('test-utils'); + + async () => { + await wait(); + await waitFor(someCallback); + }`, + errors: [ + { + messageId: 'preferWaitForRequire', + line: 1, + column: 7, + }, + { + messageId: 'preferWaitForMethod', + line: 4, + column: 15, + }, + ], + output: `const { render,waitFor } = require('test-utils'); async () => { await waitFor(() => {}); diff --git a/tests/lib/rules/render-result-naming-convention.test.ts b/tests/lib/rules/render-result-naming-convention.test.ts new file mode 100644 index 00000000..9043ca5f --- /dev/null +++ b/tests/lib/rules/render-result-naming-convention.test.ts @@ -0,0 +1,512 @@ +import { createRuleTester } from '../test-utils'; +import rule, { + RULE_NAME, +} from '../../../lib/rules/render-result-naming-convention'; + +const ruleTester = createRuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: ` + import { render } from '@testing-library/react'; + + test('should not report straight destructured render result', () => { + const { rerender, getByText } = render(); + const button = getByText('some button'); + }); + `, + }, + { + code: ` + import * as RTL from '@testing-library/react'; + + test('should not report straight destructured render result from wildcard import', () => { + const { rerender, getByText } = RTL.render(); + const button = getByText('some button'); + }); + `, + }, + { + code: ` + import { render } from '@testing-library/react'; + + test('should not report straight render result called "utils"', async () => { + const utils = render(); + await utils.findByRole('button'); + }); + `, + }, + { + code: ` + import { render } from '@testing-library/react'; + + test('should not report straight render result called "view"', async () => { + const view = render(); + await view.findByRole('button'); + }); + `, + }, + { + code: ` + import { render } from '@testing-library/react'; + + const setup = () => render(); + + test('should not report destructured render result from wrapping function', () => { + const { rerender, getByText } = setup(); + const button = getByText('some button'); + }); + `, + }, + { + code: ` + import { render } from '@testing-library/react'; + + const setup = () => render(); + + test('should not report render result called "utils" from wrapping function', async () => { + const utils = setup(); + await utils.findByRole('button'); + }); + `, + }, + { + code: ` + import { render } from '@testing-library/react'; + + const setup = () => render(); + + test('should not report render result called "view" from wrapping function', async () => { + const view = setup(); + await view.findByRole('button'); + }); + `, + }, + { + code: ` + import { screen } from '@testing-library/react'; + import { customRender } from 'test-utils'; + + test('should not report straight destructured render result from custom render', () => { + const { unmount } = customRender(); + const button = screen.getByText('some button'); + }); + `, + settings: { 'testing-library/custom-renders': ['customRender'] }, + }, + { + code: ` + import { customRender } from 'test-utils'; + + test('should not report render result called "view" from custom render', async () => { + const view = customRender(); + await view.findByRole('button'); + }); + `, + settings: { 'testing-library/custom-renders': ['customRender'] }, + }, + { + code: ` + import { customRender } from 'test-utils'; + + test('should not report render result called "utils" from custom render', async () => { + const utils = customRender(); + await utils.findByRole('button'); + }); + `, + settings: { 'testing-library/custom-renders': ['customRender'] }, + }, + { + code: ` + import { render } from '@testing-library/react'; + + const setup = () => { + // this one must have a valid name + const view = render(); + return view; + }; + + test('should not report render result called "view" from wrapping function', async () => { + // this isn't a render technically so it can be called "wrapper" + const wrapper = setup(); + await wrapper.findByRole('button'); + }); + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render as testingLibraryRender } from '@testing-library/react'; + import { render } from '@somewhere/else' + + const setup = () => render(); + + test('aggressive reporting disabled - should not report nested render not related to TL', () => { + const wrapper = setup(); + const button = wrapper.getByText('some button'); + }); + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + 'testing-library/custom-renders': ['customRender'], + }, + code: ` + import { customRender as myRender } from 'test-utils'; + import { customRender } from 'non-related' + + const setup = () => { + return customRender(); + }; + + test( + 'both render and module aggressive reporting disabled - should not report render result called "wrapper" from nont-related renamed custom render wrapped in a function', + async () => { + const wrapper = setup(); + await wrapper.findByRole('button'); + }); + `, + }, + ], + invalid: [ + { + code: ` + import { render } from '@testing-library/react'; + + test('should report straight render result called "wrapper"', async () => { + const wrapper = render(); + await wrapper.findByRole('button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 5, + column: 17, + }, + ], + }, + { + code: ` + import * as RTL from '@testing-library/react'; + + test('should report straight render result called "wrapper" from wildcard import', () => { + const wrapper = RTL.render(); + const button = wrapper.getByText('some button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 5, + column: 17, + }, + ], + }, + { + code: ` + import { render } from '@testing-library/react'; + + test('should report straight render result called "component"', async () => { + const component = render(); + await component.findByRole('button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'component', + }, + line: 5, + column: 17, + }, + ], + }, + { + code: ` + import { render } from '@testing-library/react'; + + test('should report straight render result called "notValidName"', async () => { + const notValidName = render(); + await notValidName.findByRole('button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + line: 5, + column: 17, + }, + ], + }, + { + code: ` + import { render as testingLibraryRender } from '@testing-library/react'; + + test('should report renamed render result called "wrapper"', async () => { + const wrapper = testingLibraryRender(); + await wrapper.findByRole('button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 5, + column: 17, + }, + ], + }, + { + code: ` + import { render } from '@testing-library/react'; + + const setup = () => { + // this one must have a valid name + const wrapper = render(); + return wrapper; + }; + + test('should report render result called "wrapper" from wrapping function', async () => { + // this isn't a render technically so it can be called "wrapper" + const wrapper = setup(); + await wrapper.findByRole('button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 6, + column: 17, + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render } from '@testing-library/react'; + + const setup = () => render(); + + test('aggressive reporting disabled - should report nested render from TL package', () => { + const wrapper = setup(); + const button = wrapper.getByText('some button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 7, + column: 17, + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render } from 'test-utils'; + + function setup() { + doSomethingElse(); + return render() + } + + test('aggressive reporting disabled - should report nested render from custom utils module', () => { + const wrapper = setup(); + const button = wrapper.getByText('some button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 10, + column: 17, + }, + ], + }, + { + code: ` + import { customRender } from 'test-utils'; + + test('should report from custom render function ', () => { + const wrapper = customRender(); + const button = wrapper.getByText('some button'); + }); + `, + settings: { 'testing-library/custom-renders': ['customRender'] }, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 5, + column: 17, + }, + ], + }, + { + code: ` + import { render } from '@foo/bar'; + + test('aggressive reporting - should report from render not related to testing library', () => { + const wrapper = render(); + const button = wrapper.getByText('some button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 5, + column: 17, + }, + ], + }, + { + code: ` + import * as RTL from '@foo/bar'; + + test('aggressive reporting - should report from wildcard render not imported from testing library', () => { + const wrapper = RTL.render(); + const button = wrapper.getByText('some button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 5, + column: 17, + }, + ], + }, + { + code: ` + function render() { + return 'whatever'; + } + + test('aggressive reporting - should report from custom render not related to testing library', () => { + const wrapper = render(); + const button = wrapper.getByText('some button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 7, + column: 17, + }, + ], + }, + { + code: ` + import { render as testingLibraryRender } from '@testing-library/react'; + + const setup = () => { + return testingLibraryRender(); + }; + + test('should report render result called "wrapper" from renamed render wrapped in a function', async () => { + const wrapper = setup(); + await wrapper.findByRole('button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 9, + column: 17, + }, + ], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { render as testingLibraryRender } from '@testing-library/react'; + + const setup = () => { + return testingLibraryRender(); + }; + + test( + 'aggressive reporting disabled - should report render result called "wrapper" from renamed render wrapped in a function', + async () => { + const wrapper = setup(); + await wrapper.findByRole('button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 11, + column: 17, + }, + ], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + 'testing-library/custom-renders': ['customRender'], + }, + code: ` + import { customRender as myRender } from 'test-utils'; + + const setup = () => { + return myRender(); + }; + + test( + 'both render and module aggressive reporting disabled - should report render result called "wrapper" from renamed custom render wrapped in a function', + async () => { + const wrapper = setup(); + await wrapper.findByRole('button'); + }); + `, + errors: [ + { + messageId: 'renderResultNamingConvention', + data: { + renderResultName: 'wrapper', + }, + line: 11, + column: 17, + }, + ], + }, + ], +}); diff --git a/tests/lib/test-utils.ts b/tests/lib/test-utils.ts index 9d7a6ded..88c1b778 100644 --- a/tests/lib/test-utils.ts +++ b/tests/lib/test-utils.ts @@ -1,14 +1,49 @@ import { resolve } from 'path'; import { TSESLint } from '@typescript-eslint/experimental-utils'; +const DEFAULT_TEST_CASE_CONFIG = { + filename: 'MyComponent.test.js', +}; + +class TestingLibraryRuleTester extends TSESLint.RuleTester { + run>( + ruleName: string, + rule: TSESLint.RuleModule, + tests: TSESLint.RunTests + ): void { + const { valid, invalid } = tests; + + const finalValid = valid.map((testCase) => { + if (typeof testCase === 'string') { + return { + ...DEFAULT_TEST_CASE_CONFIG, + code: testCase, + }; + } + + return { ...DEFAULT_TEST_CASE_CONFIG, ...testCase }; + }); + const finalInvalid = invalid.map((testCase) => ({ + ...DEFAULT_TEST_CASE_CONFIG, + ...testCase, + })); + + super.run(ruleName, rule, { valid: finalValid, invalid: finalInvalid }); + } +} + export const createRuleTester = ( parserOptions: Partial = {} -) => - new TSESLint.RuleTester({ +): TSESLint.RuleTester => { + return new TestingLibraryRuleTester({ parser: resolve('./node_modules/@typescript-eslint/parser'), parserOptions: { ecmaVersion: 2018, sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, ...parserOptions, }, }); +}; diff --git a/tsconfig.json b/tsconfig.json index cddbaa37..863be8e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "target": "es5", + "strict": true, + "target": "es6", "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true,