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',
};
(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',
},
],
},
};
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;
}
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);
});
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:
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);
});
Out coverage now is much better:
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;
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