Skip to content

Commit d3d38b6

Browse files
committed
feat(rule): add data-testid rule
1 parent f1016f4 commit d3d38b6

File tree

5 files changed

+346
-0
lines changed

5 files changed

+346
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ To enable this configuration use the `extends` property in your
138138
| [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
139139
| [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][] | |
140140
| [prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | |
141+
| [data-testid](docs/rules/data-testid.md) | Ensure `data-testid` values match a provided regex. | | |
141142

142143
[build-badge]: https://img.shields.io/travis/Belco90/eslint-plugin-testing-library?style=flat-square
143144
[build-url]: https://travis-ci.org/belco90/eslint-plugin-testing-library

docs/rules/data-testid.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Enforces consistent naming for the data-testid attribute (data-testid)
2+
3+
Ensure `data-testid` values match a provided regex. This rule is un-opinionated, and requires configuration.
4+
5+
## Rule Details
6+
7+
> Assuming the rule has been configured with the following regex: `^TestId(\_\_[A-Z]*)?$`
8+
9+
Examples of **incorrect** code for this rule:
10+
11+
```js
12+
const foo = props => <div data-testid="my-test-id">...</div>;
13+
const foo = props => <div data-testid="myTestId">...</div>;
14+
const foo = props => <div data-testid="TestIdEXAMPLE">...</div>;
15+
```
16+
17+
Examples of **correct** code for this rule:
18+
19+
```js
20+
const foo = props => <div data-testid="TestId__EXAMPLE">...</div>;
21+
22+
const bar = props => <div data-testid="TestId">...</div>;
23+
24+
const baz = props => <div>...</div>;
25+
```
26+
27+
## Options
28+
29+
| Option | Details | Example |
30+
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
31+
| testIdPattern | A regex used to validate the format of the `data-testid` value. `{componentName}` 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 fileName is `index.js` | `'^{componentName}(\_\_([A-Z]+[a-z]_?)+)_\$'` |
32+
| excludePaths | An array of path strings to exclude from the check | `["__tests__"]` |
33+
34+
## Example
35+
36+
```json
37+
{
38+
"testing-library/data-testid": [
39+
2,
40+
{
41+
"testIdPattern": "^TestId(__[A-Z]*)?$",
42+
"excludePaths": ["__tests__"]
43+
}
44+
]
45+
}
46+
```

lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const rules = {
44
'await-async-query': require('./rules/await-async-query'),
55
'await-fire-event': require('./rules/await-fire-event'),
6+
'data-testid': require('./rules/data-testid'),
67
'no-await-sync-query': require('./rules/no-await-sync-query'),
78
'no-debug': require('./rules/no-debug'),
89
'no-dom-import': require('./rules/no-dom-import'),

lib/rules/data-testid.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use strict';
2+
3+
module.exports = {
4+
meta: {
5+
docs: {
6+
description: 'Ensures consistent usage of `data-testid`',
7+
category: 'Best Practices',
8+
recommended: false,
9+
},
10+
messages: {
11+
invalidTestId: '`data-testid` "{{value}}" should match `{{regex}}`',
12+
},
13+
fixable: null,
14+
schema: [
15+
{
16+
type: 'object',
17+
additionalProperties: false,
18+
properties: {
19+
testIdPattern: {
20+
type: 'string',
21+
default: '',
22+
},
23+
excludePaths: {
24+
type: 'array',
25+
items: { type: 'string' },
26+
default: [],
27+
},
28+
},
29+
},
30+
],
31+
},
32+
33+
create: function(context) {
34+
const { options, getFilename } = context;
35+
const defaultOptions = { testIdPattern: '', excludePaths: [] };
36+
const ruleOptions = options.length ? options[0] : defaultOptions;
37+
38+
function getComponentData() {
39+
const splitPath = getFilename().split('/');
40+
const exclude = ruleOptions.excludePaths.some(path =>
41+
splitPath.includes(path)
42+
);
43+
const fileNameWithExtension = splitPath.pop();
44+
const parent = splitPath.pop();
45+
const fileName = fileNameWithExtension.split('.').shift();
46+
47+
return {
48+
componentDescriptor: fileName === 'index' ? parent : fileName,
49+
exclude,
50+
};
51+
}
52+
53+
function getTestIdValidator({ componentName }) {
54+
return new RegExp(
55+
ruleOptions.testIdPattern.replace('{componentName}', componentName)
56+
);
57+
}
58+
59+
return {
60+
'JSXIdentifier[name=data-testid]': node => {
61+
const { value } = (node && node.parent && node.parent.value) || {};
62+
const {
63+
componentDescriptor: componentName,
64+
exclude,
65+
} = getComponentData();
66+
const regex = getTestIdValidator({ componentName });
67+
68+
if (!exclude && value && !regex.test(value)) {
69+
context.report({
70+
node,
71+
messageId: 'invalidTestId',
72+
data: {
73+
value,
74+
regex,
75+
},
76+
});
77+
}
78+
},
79+
};
80+
},
81+
};

tests/lib/rules/data-testid.js

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
'use strict';
2+
3+
// ------------------------------------------------------------------------------
4+
// Requirements
5+
// ------------------------------------------------------------------------------
6+
7+
const rule = require('../../../lib/rules/data-testid');
8+
const RuleTester = require('eslint').RuleTester;
9+
10+
// ------------------------------------------------------------------------------
11+
// Tests
12+
// ------------------------------------------------------------------------------
13+
14+
const parserOptions = {
15+
ecmaVersion: 2018,
16+
sourceType: 'module',
17+
ecmaFeatures: {
18+
jsx: true,
19+
},
20+
};
21+
22+
const ruleTester = new RuleTester({ parserOptions });
23+
ruleTester.run('data-testid', rule, {
24+
valid: [
25+
{
26+
code: `
27+
import React from 'react';
28+
29+
const TestComponent = props => {
30+
return (
31+
<div data-testid="cool">
32+
Hello
33+
</div>
34+
)
35+
};
36+
`,
37+
options: [],
38+
},
39+
{
40+
code: `
41+
import React from 'react';
42+
43+
const TestComponent = props => {
44+
return (
45+
<div data-testid="cool">
46+
Hello
47+
</div>
48+
)
49+
};
50+
`,
51+
options: [{ testIdPattern: 'cool' }],
52+
},
53+
{
54+
code: `
55+
import React from 'react';
56+
57+
const TestComponent = props => {
58+
return (
59+
<div className="cool">
60+
Hello
61+
</div>
62+
)
63+
};
64+
`,
65+
options: [{ testIdPattern: 'cool' }],
66+
},
67+
{
68+
code: `
69+
import React from 'react';
70+
71+
const TestComponent = props => {
72+
return (
73+
<div data-testid="Awesome__CoolStuff">
74+
Hello
75+
</div>
76+
)
77+
};
78+
`,
79+
options: [
80+
{
81+
testIdPattern: '^{componentName}(__([A-Z]+[a-z]*?)+)*$',
82+
},
83+
],
84+
filename: '/my/cool/file/path/Awesome.js',
85+
},
86+
{
87+
code: `
88+
import React from 'react';
89+
90+
const TestComponent = props => {
91+
return (
92+
<div data-testid="Awesome">
93+
Hello
94+
</div>
95+
)
96+
};
97+
`,
98+
options: [
99+
{
100+
testIdPattern: '^{componentName}(__([A-Z]+[a-z]*?)+)*$',
101+
},
102+
],
103+
filename: '/my/cool/file/path/Awesome.js',
104+
},
105+
{
106+
code: `
107+
import React from 'react';
108+
109+
const TestComponent = props => {
110+
return (
111+
<div data-testid="Parent">
112+
Hello
113+
</div>
114+
)
115+
};
116+
`,
117+
options: [
118+
{
119+
testIdPattern: '^{componentName}(__([A-Z]+[a-z]*?)+)*$',
120+
},
121+
],
122+
filename: '/my/cool/file/Parent/index.js',
123+
},
124+
{
125+
code: `
126+
import React from 'react';
127+
128+
const TestComponent = props => {
129+
return (
130+
<div data-testid="Parent">
131+
Hello
132+
</div>
133+
)
134+
};
135+
`,
136+
options: [
137+
{
138+
testIdPattern: '^{componentName}(__([A-Z]+[a-z]*?)+)*$',
139+
excludePaths: ['__tests__'],
140+
},
141+
],
142+
filename: '/my/cool/__tests__/Parent/index.js',
143+
},
144+
],
145+
invalid: [
146+
{
147+
code: `
148+
import React from 'react';
149+
150+
const TestComponent = props => {
151+
return (
152+
<div data-testid="Awesome__CoolStuff">
153+
Hello
154+
</div>
155+
)
156+
};
157+
`,
158+
options: [{ testIdPattern: 'error' }],
159+
errors: [
160+
{
161+
message: '`data-testid` "Awesome__CoolStuff" should match `/error/`',
162+
},
163+
],
164+
},
165+
{
166+
code: `
167+
import React from 'react';
168+
169+
const TestComponent = props => {
170+
return (
171+
<div data-testid="Nope">
172+
Hello
173+
</div>
174+
)
175+
};
176+
`,
177+
options: [
178+
{
179+
testIdPattern: 'matchMe',
180+
excludePaths: ['__mocks__'],
181+
},
182+
],
183+
filename: '/my/cool/__tests__/Parent/index.js',
184+
errors: [
185+
{
186+
message: '`data-testid` "Nope" should match `/matchMe/`',
187+
},
188+
],
189+
},
190+
{
191+
code: `
192+
import React from 'react';
193+
194+
const TestComponent = props => {
195+
return (
196+
<div data-testid="WrongComponent__cool">
197+
Hello
198+
</div>
199+
)
200+
};
201+
`,
202+
options: [
203+
{
204+
testIdPattern: '^{componentName}(__([A-Z]+[a-z]*?)+)*$',
205+
excludePaths: ['__mocks__'],
206+
},
207+
],
208+
filename: '/my/cool/__tests__/Parent/index.js',
209+
errors: [
210+
{
211+
message:
212+
'`data-testid` "WrongComponent__cool" should match `/^Parent(__([A-Z]+[a-z]*?)+)*$/`',
213+
},
214+
],
215+
},
216+
],
217+
});

0 commit comments

Comments
 (0)