Testing Your Stylelint Plugin

Matti Bar-Zeev - Dec 23 '22 - - Dev Community

In the previous post I’ve created a Stylelint plugin that has a single rule for validating that certain CSS properties can only be found in certain files. That’s nice, but it was missing something, a thing which is dear to my heart - tests.
Now, you know me as a TDD advocate who truly believes that “testing-after” is a bad practice, so you’re probably wondering how this could be. The simple answer is that I thought that testing a Stylelint plugin deserves an article of its own, so here it is :)

In this post I will write a test suite for my @pedalboard/stylelint-plugin-craftsmanlint. We will be using jest-preset-stylelint to help us with that, and as a bonus, I’ll fortify the plugin’s TypeScript support.

(BTW, the plugin can be found on NPM registry)

Let the testing commence!


We start with a test file, under the stylelint-plugin-craftsmamlint directory, named index.test.ts
As the docs suggest we will install jest-preset-stylelint:

yarn add -D jest-preset-stylelint

I will create a jest.config.ts file for the package and add the following configuration to it, with the preset previously installed:

const sharedConfig = require('../../jest.config.base');
module.exports = {
   ...sharedConfig,
   testEnvironment: 'node',
   preset: 'jest-preset-stylelint',
};
Enter fullscreen mode Exit fullscreen mode

(Notice that the configuration above is extending a common configuration I have in my monorepo. You can read more about it here)

We can now start writing our first tests. As the docs says: “The preset exposes a global testRule function that you can use to efficiently test your plugin using a schema.”. A-ha, ok… let’s find out what this means…

Since our plugin is validating files, we would like to use fixtures files to simulate what will happen in real use cases.

In the test, we will first focus on the configuration:

const config = {
   plugins: ['./index.ts'],
   rules: {
       'stylelint-plugin-craftsmanlint/props-in-files': [
           {
               'font-family': {
                   forbidden: ['contains-prop.css'],
               },
           },
           {
               severity: 'error',
           },
       ],
   },
};
Enter fullscreen mode Exit fullscreen mode

We’re loading our plugin, set the configuration we want to test and then set the severity to error.

Next we set a fixture file we can perform the test on. I’m calling the file contains-prop.css and add the following content to it:

.my-class {
   font-family: 'Courier New', Courier, monospace;
}
Enter fullscreen mode Exit fullscreen mode

Back to our test, here is the first test case:

it('should error on files that contain a prop they should not', async () => {
   const config = {
       plugins: ['./index.ts'],
       rules: {
           'stylelint-plugin-craftsmanlint/props-in-files': [
               {
                   'font-family': {
                       forbidden: ['contains-prop.css'],
                   },
               },
               {
                   severity: 'error',
               },
           ],
       },
   };

   const {
       results: [{warnings, errored, parseErrors}],
   } = await lint({
       files: 'src/rules/props-in-files/fixtures/contains-prop.css',
       config,
   });

   expect(errored).toEqual(true);
   expect(parseErrors).toHaveLength(0);
   expect(warnings).toHaveLength(1);

   const [{line, column, text}] = warnings;

   expect(text).toBe(
       '"font-family" CSS property was found in a file it should not be in (stylelint-plugin-craftsmanlint/props-in-files)'
   );
   expect(line).toBe(2);
   expect(column).toBe(5);
});

Enter fullscreen mode Exit fullscreen mode

In the code above we test that the font-family CSS property should not be in the contains-prop.css. You can see that we’re linting the fixture file, and then do some assertions - we make sure that we get and error, using the “errored” property (this will be false in case we’re using a “warning” severity) and the other assertions check the message and location of the error.

Running the coverage report over this and we see that we are still not well covered:

Image description

We only checked the “forbidden” configuration. Let’s check the “allowed” one as well. In another test case I’m defining that it is allowed for the fixture file to have the css property:

it('should be valid on files that contain a prop they are allowed to ', async () => {
   const config = {
       plugins: ['./index.ts'],
       rules: {
           'stylelint-plugin-craftsmanlint/props-in-files': [
               {
                   'font-family': {
                       allowed: ['contains-prop.css'],
                   },
               },
               {
                   severity: 'error',
               },
           ],
       },
   };

   const {
       results: [{warnings, errored, parseErrors}],
   } = await lint({
       files: 'src/rules/props-in-files/fixtures/contains-prop.css',
       config,
   });

   expect(errored).toEqual(false);
   expect(parseErrors).toHaveLength(0);
   expect(warnings).toHaveLength(0);
});
Enter fullscreen mode Exit fullscreen mode

Out coverage now is much better:

Image description

Yeah, I think that this covers it well enough, both forbidden and allowed flows.
It's that simple and we have our plugin well covered :)
You know what? Since it was that smooth, I’m going to take the time left and fortify TypeScript support the plugin has. Check this out -


TypeScript

A debt from last post where I neglected TypeScript kept me awake at nights (no, not really), so here is a better typed Styleint rule code.
I’m using the csstype package for some standard CSS types and the type from postcss.
In addition to that I also created a few custom types, such as Policy, PrimaryOption and SecondaryOption.
Here is the final result:

/**
* Copyright (c) 2022-present, Matti Bar-Zeev.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import stylelint from 'stylelint';
import * as CSS from 'csstype';
import type * as PostCSS from 'postcss';

type Policy = Record<'forbidden' | 'allowed', string[]>;
type PrimaryOption = Record<keyof CSS.StandardPropertiesHyphen, Partial<Policy>>;
type SecondaryOption = Record<'severity', 'error' | 'warning'>;

const ruleName = 'stylelint-plugin-craftsmanlint/props-in-files';
const messages = stylelint.utils.ruleMessages(ruleName, {
   expected: (property: string) => `"${property}" CSS property was found in a file it should not be in`,
});
const meta = {
   url: 'https://github.com/mbarzeev/pedalboard/blob/master/packages/stylelint-plugin-craftsmanlint/README.md',
};

const ruleFunction = (primaryOption: PrimaryOption, secondaryOptionObject: SecondaryOption) => {
   return (postcssRoot: PostCSS.Root, postcssResult: stylelint.PostcssResult) => {
       const validOptions = stylelint.utils.validateOptions(postcssResult, ruleName, {
           actual: null,
       });

       if (!validOptions) {
           return;
       }

       postcssRoot.walkDecls((decl: PostCSS.Declaration) => {
           // Iterate CSS declarations
           const propRule = primaryOption[decl.prop as keyof CSS.StandardPropertiesHyphen];

           if (!propRule) {
               return;
           }

           const file = postcssRoot?.source?.input?.file;
           const allowedFiles = propRule.allowed;
           const forbiddenFiles = propRule.forbidden;
           let shouldReport = false;
           const isFileInList = (inspectedFile: string) => file?.includes(inspectedFile);

           if (allowedFiles) {
               shouldReport = !allowedFiles.some(isFileInList);
           }

           if (forbiddenFiles) {
               shouldReport = forbiddenFiles.some(isFileInList);
           }

           if (!shouldReport) {
               return;
           }

           stylelint.utils.report({
               ruleName,
               result: postcssResult,
               message: messages.expected(decl.prop),
               node: decl,
           });
       });
   };
};

ruleFunction.ruleName = ruleName;
ruleFunction.messages = messages;
ruleFunction.meta = meta;

export default ruleFunction;
Enter fullscreen mode Exit fullscreen mode

And that’s it 🙂

As always, if you have any questions or comments please leave them in the comments section below so that we can all learn from them.

Hey! for more content like the one you've just read check out @mattibarzeev on Twitter 🍻

Photo by Steve Johnson on Unsplash

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