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.

Available for Node.js and TypeScript contracts

I take over platforms that already exist and are in trouble. Node, TypeScript, React and Laravel, mostly in regulated or high-traffic environments. If something needs rescuing, re-platforming or finishing, my full history is on the CV.

Related Posts

Comments