Creating a Custom ESLint Rule
What We’re Building
A custom ESLint rule that enforces a project-specific convention-in this case, requiring all React components to have a data-testid attribute for testing.
Prerequisites
- ESLint configured in your project
- Basic understanding of AST (Abstract Syntax Trees)
- TypeScript
The Approach
- Understand ESLint rule structure
- Explore the AST for your pattern
- Write the rule
- Add tests
- Integrate into your config
Step 1: Set Up the Plugin
mkdir eslint-plugin-custom && cd eslint-plugin-custom
npm init -y
npm install -D @types/eslint typescript
Create src/rules/require-testid.ts:
import { Rule } from 'eslint';
const rule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'Require data-testid on JSX elements',
},
schema: [],
},
create(context) {
return {
JSXOpeningElement(node: any) {
// Implementation goes here
},
};
},
};
export default rule;
Step 2: Explore the AST
Use AST Explorer to understand the structure.
For <Button onClick={}>Click</Button>, the AST shows:
{
"type": "JSXOpeningElement",
"name": { "type": "JSXIdentifier", "name": "Button" },
"attributes": [
{ "type": "JSXAttribute", "name": { "name": "onClick" } }
]
}
Step 3: Implement the Rule
import { Rule } from 'eslint';
import { JSXOpeningElement, JSXAttribute } from 'estree-jsx';
const rule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'Require data-testid on interactive JSX elements',
},
fixable: 'code',
schema: [],
},
create(context) {
const interactiveElements = ['button', 'a', 'input', 'select', 'textarea'];
return {
JSXOpeningElement(node: any) {
const elementName = node.name.name?.toLowerCase();
if (!interactiveElements.includes(elementName)) {
return;
}
const hasTestId = node.attributes.some(
(attr: any) =>
attr.type === 'JSXAttribute' &&
attr.name.name === 'data-testid'
);
if (!hasTestId) {
context.report({
node,
message: `Interactive element <${elementName}> should have a data-testid attribute`,
});
}
},
};
},
};
export default rule;
Step 4: Add Tests
import { RuleTester } from 'eslint';
import rule from './require-testid';
const tester = new RuleTester({
parserOptions: {
ecmaVersion: 2020,
ecmaFeatures: { jsx: true },
},
});
tester.run('require-testid', rule, {
valid: [
'<button data-testid="submit-btn">Submit</button>',
'<div>Not interactive</div>',
],
invalid: [
{
code: '<button>Submit</button>',
errors: [{ message: /should have a data-testid/ }],
},
],
});
Step 5: Create the Plugin Index
Create src/index.ts:
import requireTestid from './rules/require-testid';
export = {
rules: {
'require-testid': requireTestid,
},
};
Step 6: Use in Your Project
In .eslintrc.js:
module.exports = {
plugins: ['custom'],
rules: {
'custom/require-testid': 'warn',
},
};
The Result
- ESLint automatically catches missing test IDs
- Consistent conventions enforced at lint time
- No more manual code review for this pattern
What I’d Do Differently
Add an auto-fix from the start. Generating data-testid from the element content or nearby text makes adoption much easier.
Custom ESLint rules feel like overkill until you realise how much review time they save.