Creating a Custom ESLint Rule with TDD

Matti Bar-Zeev - Dec 3 '21 - - Dev Community

In this post join me as I create a simple ESLint rule using TDD :)

As always I start with the requirements. My custom rule makes sure that a developer will not be able to import a namespace (“import * as ...”) from modules, with the option to configure it to disallow a namespace imports from certain modules.

Before you jump, I know there are probably rules of this sort out there (like no-restricted-imports) but that’s not the point of this post, nor is publishing your ESLint plugin. The point is to see how one can approach building a custom ESLint rule while practicing TDD.

Let’s start.

I start by installing my test runner with npm i -D jest. I will use Jest but you can choose whichever works for you.
I call my rule no-namespace-imports, and that means I have a directory with that name and residing in it are 2 files: index.js and index.test.js.

I begin with our tests -
For testing the rule I’m going to use the RuleTester which is a utility for writing tests for ESLint rules, and quite a good one at it.
There is a nice thing about using the RuleTester - It abstracts the “describe” and “it” and provides a different syntax to easily check if a rule enforces what it should. This helps us to jump right in into checking he rule’s logic:

const {RuleTester} = require('eslint');
const rule = require('./index');

const ruleTester = new RuleTester({parserOptions: {ecmaVersion: 2015, sourceType: 'module'}});

ruleTester.run('no-namespace-imports rule', rule, {
   valid: [
       {
           code: `import {chuck} from './norris'`,
       },
   ],

   invalid: [
       {
           code: `import * as chuck from './norris'`,
           errors: [{message: 'Importing a namespace is not allowed.'}],
       },
   ],
});
Enter fullscreen mode Exit fullscreen mode

We first start with creating a RuleTester instance with the parseOptions that can handle ESM imports, otherwise it won’t be able to parse the “import” statements we’re interested in.
Look at the test above - the string arg of the run() method is equal to the describe string we usually use in tests, then we give it the rule instance and in the end we have 2 use cases, one valid and one that is not. This format is strict, meaning that if we neglect one of the use cases the test will fail right away.
Our test is very naive at this point, but if we run it we get the following outcome:

TypeError: Error while loading rule 'no-namespace-imports rule': (intermediate value)(intermediate value)(intermediate value) is not a function
Enter fullscreen mode Exit fullscreen mode

This happens because our rule currently doesn’t have any implementation. Let’s jump to it and start putting some content in it following the ESLint rule’s format and guidelines:

module.exports = {
   create: (context) => {
       return {};
   },
};
Enter fullscreen mode Exit fullscreen mode

Running the test again and we get a different result. The first “valid” scenario passes, but the “invlid” scenario does not. Well, there is no logic checking anything so obviously the “valid” scenario passes, so let’s put our logic now.
This requires some JS AST (Abstract Syntax Tree) knowledge which, I have to admit, I’m not swimming freely in, but let’s go step by step and find our path. I add to the rule an “ImportDeclaration” visitor handler to see what I get:

module.exports = {
   create: (context) => {
       return {
           ImportDeclaration: function (node) {
               console.log(node);
           },
       };
   },
};
Enter fullscreen mode Exit fullscreen mode

When I run the test the output tells me that there are 2 different types of imports at stake: one is an “ImportSpecifier” and the other is an “ImportNamespaceSpecifier”. Hmm… what is the difference between them? From what I read “ImportNamespaceSpecifier” is the representation of “import * as ....” and this is what our rule is interested in! so what we need to check is that if there is a an “ImportNamespaceSpecifier” and then report it:

module.exports = {
   create: (context) => {
       return {
           ImportDeclaration: function (node) {
               if (node.specifiers[0].type === 'ImportNamespaceSpecifier') {
                   context.report({
                       node,
                       message: 'Importing a namespace is not allowed.',
                   });
               }
           },
       };
   },
};
Enter fullscreen mode Exit fullscreen mode

The tests pass and we know that our rule has a solid start. Let’s add a different types of valid and invalid imports just to make sure the logic is sound:

const {RuleTester} = require('eslint');
const rule = require('./index');

const ruleTester = new RuleTester({parserOptions: {ecmaVersion: 2015, sourceType: 'module'}});

ruleTester.run('no-namespace-imports rule', rule, {
   valid: [
       {
           code: `import {chuck} from './norris'`,
       },
       {
           code: `import {john as chuck} from './norris'`,
       },
       {
           code: `import {john as chuck} from './norris'`,
       },
       {
           code: `import defaultExport from "module-name"`,
       },
       {
           code: `import { export1 , export2 } from "module-name";`,
       },
   ],

   invalid: [
       {
           code: `import * as chuck from './norris'`,
           errors: [{message: 'Importing a namespace is not allowed.'}],
       },
       {
           code: `import defaultExport, * as name from "module-name";`,
           errors: [{message: 'Importing a namespace is not allowed.'}],
       },
   ],
});
Enter fullscreen mode Exit fullscreen mode

Whoops! While all the valid cases pass, the second invalid case fails, and I think I know what the issue is here! I’m checking the 1st specifier only, but here we have the 2nd specifier which is an “ImportNamespaceSpecifier”, so let’s make our check a bit more robust, that is, if one of the specifiers is an “ImportNamespaceSpecifier” the rule should report :

module.exports = {
   create: (context) => {
       return {
           ImportDeclaration: function (node) {
               console.log('node :>> ', node);

               const hasNamespaceSpecifier = node.specifiers.some(
                   (specifier) => specifier.type === 'ImportNamespaceSpecifier'
               );

               if (hasNamespaceSpecifier) {
                   context.report({
                       node,
                       message: 'Importing a namespace is not allowed.',
                   });
               }
           },
       };
   },
};
Enter fullscreen mode Exit fullscreen mode

Yeah, that’s better.
We got the basic logic locked, but the rule is a bit rigid. I’d like to give it more flexibility by allowing those who use it to give it a set of modules for which namespace imports are forbidden while allowing the rest. I’m adding this case to the valid cases:

{
           code: `import * as chuck from './allowed/module'`,
           options: ['./forbidden/module'],
       },
Enter fullscreen mode Exit fullscreen mode

This test checks that if the namespace import is from an allowed module, while there is a configuration which specifies the forbidden ones, it is valid. Here is the code, but bear in mind that it is pre-refactoring phase:

module.exports = {
   create: (context) => {
       return {
           ImportDeclaration: function (node) {
               const hasNamespaceSpecifier = node.specifiers.some((specifier) => {
                   return specifier.type === 'ImportNamespaceSpecifier';
               });

               if (hasNamespaceSpecifier) {
                   // If there are forbidden modules configuration, check if the
                   // source module is among them, and only if it is - report
                   if (context.options.length) {
                       const sourceModule = node.source.value;
                       if (context.options.includes(sourceModule)) {
                           context.report({
                               node,
                               message: 'Importing a namespace is not allowed.',
                           });
                       }
                   } else {
                       context.report({
                           node,
                           message: 'Importing a namespace is not allowed.',
                       });
                   }
               }
           },
       };
   },
};
Enter fullscreen mode Exit fullscreen mode

Let’s refactor it now, while our tests keep us safe:

if (hasNamespaceSpecifier) {
                   // If there are forbidden modules configuration, check if the
                   // source module is among them, and only if it is - report
                   let shouldReport = true;
                   if (context.options.length) {
                       const sourceModule = node.source.value;
                       shouldReport = context.options.includes(sourceModule);
                   }

                   if (shouldReport) {
                       context.report({
                           node,
                           message: 'Importing a namespace is not allowed.',
                       });
                   }
               }
Enter fullscreen mode Exit fullscreen mode

Better :) Let’s continue.

I would like to add a test to the invalid section just to make sure that it reports when there is a forbidden module configured and as part of it also include the source module name in the report message:

invalid: [
       {
           code: `import * as chuck from './norris'`,
           errors: [{message: 'Importing a namespace is not allowed for "./norris".'}],
       },
       {
           code: `import defaultExport, * as name from "module-name";`,
           errors: [{message: 'Importing a namespace is not allowed for "module-name".'}],
       },
       {
           code: `import * as chuck from './forbidden/module'`,
           options: ['./forbidden/module'],
           errors: [{message: 'Importing a namespace is not allowed for "./forbidden/module".'}],
       },
   ],
Enter fullscreen mode Exit fullscreen mode

All the invalid tests fail of course. I will fix it and... there we have it - A simple ESlint rule which was created using TDD. I will add some “meta” to it, just to give it finishing touches:

module.exports = {
   meta: {
       type: 'problem',

       docs: {
           description: 'disallow namespace imports',
           recommended: false,
       },
   },
   create: (context) => {
       return {
           ImportDeclaration: function (node) {
               const hasNamespaceSpecifier = node.specifiers.some((specifier) => {
                   return specifier.type === 'ImportNamespaceSpecifier';
               });

               if (hasNamespaceSpecifier) {
                   // If there are forbidden modules configuration, check if the
                   // source module is among them, and only if it is - report
                   let shouldReport = true;
                   const sourceModule = node.source.value;
                   if (context.options.length) {
                       shouldReport = context.options.includes(sourceModule);
                   }

                   if (shouldReport) {
                       context.report({
                           node,
                           message: 'Importing a namespace is not allowed for "{{sourceModule}}".',
                           data: {
                               sourceModule,
                           },
                       });
                   }
               }
           },
       };
   },
};
Enter fullscreen mode Exit fullscreen mode

And here are the complete tests:

const {RuleTester} = require('eslint');
const rule = require('./index');

const ruleTester = new RuleTester({parserOptions: {ecmaVersion: 2015, sourceType: 'module'}});

ruleTester.run('no-namespace-imports rule', rule, {
   valid: [
       {
           code: `import {chuck} from './norris'`,
       },
       {
           code: `import {john as chuck} from './norris'`,
       },
       {
           code: `import {john as chuck} from './norris'`,
       },
       {
           code: `import defaultExport from "module-name"`,
       },
       {
           code: `import { export1 , export2 } from "module-name";`,
       },
       {
           code: `import * as chuck from './allowed/module'`,
           options: ['./forbidden/module'],
       },
   ],

   invalid: [
       {
           code: `import * as chuck from './norris'`,
           errors: [{message: 'Importing a namespace is not allowed.'}],
       },
       {
           code: `import defaultExport, * as name from "module-name";`,
           errors: [{message: 'Importing a namespace is not allowed.'}],
       },
       {
           code: `import * as chuck from './forbidden/module'`,
           options: ['./forbidden/module'],
           errors: [{message: 'Importing a namespace is not allowed.'}],
       },
   ],
});
Enter fullscreen mode Exit fullscreen mode

As always, if you have any ideas on how to make this better or any other technique, be sure to share with the rest of us!

Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻

Photo by Glenn Carstens-Peters on Unsplash

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .