diff --git a/README.md b/README.md index 190e1159..a5b2f21b 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ To enable this configuration use the `extends` property in your | [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] | | [prefer-expect-query-by](docs/rules/prefer-expect-query-by.md) | Disallow the use of `expect(getBy*)` | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | | [prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | | +| [consistent-data-testid](docs/rules/consistent-data-testid.md) | Ensure `data-testid` values match a provided regex. | | | [build-badge]: https://img.shields.io/travis/Belco90/eslint-plugin-testing-library?style=flat-square [build-url]: https://travis-ci.org/belco90/eslint-plugin-testing-library diff --git a/docs/rules/consistent-data-testid.md b/docs/rules/consistent-data-testid.md new file mode 100644 index 00000000..9ec5589e --- /dev/null +++ b/docs/rules/consistent-data-testid.md @@ -0,0 +1,43 @@ +# Enforces consistent naming for the data-testid attribute (consistent-data-testid) + +Ensure `data-testid` values match a provided regex. This rule is un-opinionated, and requires configuration. + +## Rule Details + +> Assuming the rule has been configured with the following regex: `^TestId(\_\_[A-Z]*)?$` + +Examples of **incorrect** code for this rule: + +```js +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 =>
...
; +``` + +## Options + +| Option | Required | Default | Details | Example | +| ----------------- | -------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| `testIdPattern` | Yes | None | A regex used to validate the format of the `data-testid` value. `{fileName}` can optionally be used as a placeholder and will be substituted with the name of the file OR the name of the files parent directory in the case when the file name is `index.js` | `^{fileName}(\_\_([A-Z]+[a-z]_?)+)_\$` | +| `testIdAttribute` | No | `data-testid` | A string used to specify the attribute used for querying by ID. This is only required if data-testid has been explicitly overridden in the [RTL configuration](https://testing-library.com/docs/dom-testing-library/api-queries#overriding-data-testid) | `data-my-test-attribute` | + +## Example + +```json +{ + "testing-library/data-testid": [ + 2, + { + "testIdPattern": "^TestId(__[A-Z]*)?$" + } + ] +} +``` diff --git a/lib/index.js b/lib/index.js index 21b3cd4d..04a2fb1f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,6 +3,7 @@ const rules = { 'await-async-query': require('./rules/await-async-query'), 'await-fire-event': require('./rules/await-fire-event'), + 'consistent-data-testid': require('./rules/consistent-data-testid'), 'no-await-sync-query': require('./rules/no-await-sync-query'), 'no-debug': require('./rules/no-debug'), 'no-dom-import': require('./rules/no-dom-import'), diff --git a/lib/rules/consistent-data-testid.js b/lib/rules/consistent-data-testid.js new file mode 100644 index 00000000..9c06c362 --- /dev/null +++ b/lib/rules/consistent-data-testid.js @@ -0,0 +1,75 @@ +'use strict'; + +const FILENAME_PLACEHOLDER = '{fileName}'; + +module.exports = { + meta: { + docs: { + description: 'Ensures consistent usage of `data-testid`', + category: 'Best Practices', + recommended: false, + }, + messages: { + invalidTestId: '`{{attr}}` "{{value}}" should match `{{regex}}`', + }, + fixable: null, + schema: [ + { + type: 'object', + default: {}, + additionalProperties: false, + required: ['testIdPattern'], + properties: { + testIdPattern: { + type: 'string', + }, + testIdAttribute: { + type: 'string', + default: 'data-testid', + }, + }, + }, + ], + }, + + create: function(context) { + const { options, getFilename } = context; + const { testIdPattern, testIdAttribute: attr } = options[0]; + + function getFileNameData() { + const splitPath = getFilename().split('/'); + const fileNameWithExtension = splitPath.pop(); + const parent = splitPath.pop(); + const fileName = fileNameWithExtension.split('.').shift(); + + return { + fileName: fileName === 'index' ? parent : fileName, + }; + } + + function getTestIdValidator({ fileName }) { + return new RegExp(testIdPattern.replace(FILENAME_PLACEHOLDER, fileName)); + } + + return { + [`JSXIdentifier[name=${attr}]`]: node => { + const value = + node && node.parent && node.parent.value && node.parent.value.value; + const { fileName } = getFileNameData(); + const regex = getTestIdValidator({ fileName }); + + if (value && !regex.test(value)) { + context.report({ + node, + messageId: 'invalidTestId', + data: { + attr, + value, + regex, + }, + }); + } + }, + }; + }, +}; diff --git a/tests/lib/rules/consistent-data-testid.js b/tests/lib/rules/consistent-data-testid.js new file mode 100644 index 00000000..401995f1 --- /dev/null +++ b/tests/lib/rules/consistent-data-testid.js @@ -0,0 +1,280 @@ +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/consistent-data-testid'); +const RuleTester = require('eslint').RuleTester; + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const parserOptions = { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, +}; + +const ruleTester = new RuleTester({ parserOptions }); +ruleTester.run('consistent-data-testid', rule, { + valid: [ + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [{ testIdPattern: 'cool' }], + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [{ testIdPattern: 'cool' }], + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [ + { + testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', + }, + ], + filename: '/my/cool/file/path/Awesome.js', + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [ + { + testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', + }, + ], + filename: '/my/cool/file/path/Awesome.js', + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [ + { + testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', + }, + ], + filename: '/my/cool/file/Parent/index.js', + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [ + { + testIdPattern: '{fileName}', + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [ + { + testIdPattern: '^right(.*)$', + testIdAttribute: 'custom-attr', + }, + ], + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [ + { + testIdPattern: '{fileName}', + testIdAttribute: 'data-test-id', + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + const dynamicTestId = 'somethingDynamic'; + return ( +
+ Hello +
+ ) + }; + `, + options: [{ testIdPattern: 'somethingElse' }], + }, + ], + invalid: [ + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [{ testIdPattern: 'error' }], + errors: [ + { + message: '`data-testid` "Awesome__CoolStuff" should match `/error/`', + }, + ], + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [ + { + testIdPattern: 'matchMe', + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + errors: [ + { + message: '`data-testid` "Nope" should match `/matchMe/`', + }, + ], + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [ + { + testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', + testIdAttribute: 'my-custom-attr', + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + errors: [ + { + message: + '`my-custom-attr` "WrongComponent__cool" should match `/^Parent(__([A-Z]+[a-z]*?)+)*$/`', + }, + ], + }, + { + code: ` + import React from 'react'; + + const TestComponent = props => { + return ( +
+ Hello +
+ ) + }; + `, + options: [ + { + testIdPattern: '^{fileName}(__([A-Z]+[a-z]*?)+)*$', + }, + ], + filename: '/my/cool/__tests__/Parent/index.js', + errors: [ + { + message: + '`data-testid` "WrongComponent__cool" should match `/^Parent(__([A-Z]+[a-z]*?)+)*$/`', + }, + ], + }, + ], +});