Creating a Custom ESLint Rule

· 3 min read
eslint typescript tooling

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

  1. Understand ESLint rule structure
  2. Explore the AST for your pattern
  3. Write the rule
  4. Add tests
  5. 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.

Related Posts

Comments