Build Testing Rules for Your Design System
Discover how to build advanced testing rules with ESLint to ensure compliance with Design System best practices
Welcome to part 2 of the 3-parts “Design System Best Practices with ESLint”.
This series aims to help you encourage those consuming your design system to follow your best practices.
We covered a lot of theory in part 1 (Use ESLint to Enforce Design System, if you have not read it yet, you should start with it), such as how ESLint works under the hood. We built some first simple rules with ESLint.
In this part 2, we will cover how to build more complex ESLint rules.
A quick reminder of Part 1
Back in the first article, we talked about how you’ll be creating two rules:
- Avoiding inline styles in your elements
- Ensuring that tooltips don’t contain interactive content.
Let’s take a moment to talk about why we’ve chosen these rules.
For starters, they’re both great rules to act as introductions to writing rules. While the implementations we cover in these articles may not be production-ready, they’ll help solidify the learning you’ve made so far.
Aside from being a teaching aid, there’s also a practical application for both these rules.
For our first rule, you might want to encourage your end-users to be consistent with their styling implementation, which may help with readability and maintenance. CSS preference varies from person-to-person, and from library-to-library, so you may find that this particular rule doesn’t apply to your library. However, targeting a specific attribute is very useful. For instance, you might want to discourage the use of tab-indexes on non-interactive elements, or you might want to encourage the use of an attribute for a certain element, like the type
attribute for a button
component.
We’ve chosen the second rule because we’ll be using an existing design system as an example, the Simba design system, which is written using the Lion web components.
In the Simba design system docs for the tooltip element, the opening description states:
“Interactive content should not be placed in a tooltip content slot. Tooltips are just meant for showing additional information.”
This might be enough to prevent some end-users from not using interactive content within the tooltip,
but some developers may not read (or remember) all rules from the docs. Having an ESLint rule that enforces the rules stated will make it much easier for people to remember and abide by the rules of your framework.
Setting up our project
Let’s begin by cloning the starter repo. The repo covers a lot of the tedious setup such as:
- Installing packages
- ESLint boilerplate
- Setting up test suites
To get started with the repo, follow the following steps:
- Clone the repo using
git clone https://github.com/andrico1234/custom-eslint-tutorial/tree/0-begin-first-rule
- Navigate into the project’s directory
- Install your dependencies with
npm i
- open the repo in your code editor
The demo repo was set up using the ESLint guide.
Here’s a quick rundown of the starter repo:
lib/rules
lib/rules
contain our rules. We've prepared the first one for you by writing the boilerplate. We've added some JSDoc annotations to the file, as per the working with rules' recommendation. This will give you some sweet, sweet intellisense to help when writing our lint logic.
tests/lib/rules
tests/lib/rules
contain the tests we'll write for our rules. We've added a handful for the no-inline-styles
, but you're welcome to add even more if you like. We're using ESLint's built-in RuleTester to test our rules. We've created an instance of the RuleTester
and am calling run
with several valid and invalid cases.
valid
is an array of stringified JavaScript that shouldn't report linting errors.
invalid
is an array of objects that contains two properties: code
and errors
. code
is the stringified JavaScript that will report linting errors. The errors
property is an array of objects. These objects can hold different types of assertions, so not only can we check to see if the snippet fails the lint rule, but we can check to see the error message we get back and the location of the error in our IDE, this is how VSCode determines where to place the squiggly lines.
Once you’re familiar with the repo, you can start the test runner by running npm test
. The test runner will run every time you make a change to the rules file, which will provide immediate feedback when something goes wrong or, more hopefully, right
Creating a simple ESLint rule
The first rule we’ll be writing will display an error message if a lit
element contains inline styles.
Creating our visitor function
Let’s begin by jumping into the lib/rules/no-inline-styles.js
file.
If you remember from the first article when our JavaScript gets parsed the html
function gets represented as a TaggedTemplateExpression
node in the AST. We can ask ESLint to visit this node whenever it reaches it by defining a TaggedTemplateExpression
in the rule's create
function.
Don’t forget that not all TaggedTemplateExpressions
will be the html
expression that we want to run our lint rule against. We'll want to check the function's name and to see if it's "html".
Try filling out the create
function with the above logic, or you can peek below for our answer.
The next step is to take the node
and parse it into a valid HTML AST.
Parsing our HTML
Since the logic to parse our node
isn't part of our rule's business logic, we’ve created a class stub in a separate utilities directory. So jump over to the utils/index.js
file.
We need to do two things here:
- Convert our template expression into an HTML string
- Parse our string into an HTML AST and store it as an instance variable
For the first task, let’s take the easy route and behave as if our Lit expressions will contain no dynamic content.
In other words let’s just worry about:
html`<div style="display:none;"></div>`;
and not:
html`<div style="${val}"></div>`;
We’ll provide the solution below, but try and use the tools we’ve run through in this article to solve this problem yourself.
You can either give it a shot yourself, or view the solution below:
Tip 1: Don’t forget to use the AST explorer to understand how to get the HTML string from the template expression
Tip 2: To parse your string to HTML, use parse5’s
parseFragment
function.
What’s the deal with templateExpressionToHtml?
The above code is ignoring more complex use cases by only pulling the first item in the quasis
array.
This means that for the following lit
expressions these tests will run fine:
html`<div style=""></div>`;
html`<div style="display:none;"></div>`;
This is because these lit
expressions don't contain any JavaScript expressions inside of the template literals.
This means the node.quasi
object will look something like this:
{
quasi: {
quasis: ['<div style="display:none;"></div>'],
expressions: [],
}
}
Unfortunately, this means that the tests will fail for the following lit
expression:
html`<div style="${val}"></div>`;
Because this lit
expression contains a JavaScript expression within the template literal, the node.quasi
object will look something like this:
{
quasi: {
quasis: ['<div style="', '"></div>'],
expressions: ['val'],
}
}
The third test case will fail until we fully implement the templateExpressionToHtml
function.
If you’ve got the tests running, all the valid cases should still be passing and all the invalid cases should be failing. This is a good indicator to see whether there are any fundamental parsing issues within our TemplateAnalyzer’s constructor.
The above code snippets are a drastically simplified example and do not match the AST structure that ESLint parses our JavaScript into.
Creating our visitor functions
The next step is to create a traversal function for our analyzer. This will enter each HTML node, as ESLint does for JavaScript, which will call a visitor function that we’ll use to make our assertions.
It will take our visitor functions as an input, and then visit every node and call our visitor.
To keep things simple, we’ll need to check to see if the node is an element, and then call the correct visitor. Don’t worry about other node types, like comment nodes or text nodes.
If you’re unsure about how to implement the visitor. You can go back to the CodeSandbox from the first article.
Our traverse function will:
- Take an object called
visitors
which contains our visitor functions - Visit each child node of
this.ast
- Call the
element
visitor function for each element node
Once implemented, the traverse
function will look something like this:
This is what the function is doing:
- A function
visit
, is declared, that handles the core logic. visit
returns early if nonode
is presentvisit
checks to see if our node is an HTML element- if it is,
visit
calls our visitor function visit
is called recursively with the child nodes.
The only other piece of functionality that’s left to implement is the isElement
helper. This helper will take a node
as an argument and check to see if the node.tagName
value is truthy.
isElement(node) {
return !!node.tagName;
}
Writing our assertions
Let’s revisit our ESLint rule now that we can start writing assertions. Let’s use the new visitor function to visit each element.
This simple helper was inspired by the parse5 repo.
We can now continue in TDD fashion by fixing the failing tests. The first failing test is for the following case:
html`<div style=""></div>`;
This case fails because the style
attribute is present. If the attribute is present, we should report an ESLint error. Take a few minutes to try and implement the attribute check before continuing. Don't forget to use the AST explorer if you're not sure about the shape of the nodes.
The next step is to report the error and we’ll do this using the context.report
function that ESLint provides. The report
function takes an object which we'll use to supply to values:
message
, which will beno-inline-styles
node
, which will be our ESLint node.
Take some time looking over the AST and give it a shot yourself. You’ll know you’ve implemented a passing solution if all, but one of, your tests pass
Here’s the solution:
Back to templateExpressionToHtml
The last thing we need to do is fix the final failing test. This means we need to make changes to our templateExpressionToHtml
function.
Since our node’s quasi
value looks like this:
{
quasi: {
quasis: ['<div style="', '"></div>'],
expressions: ['val'],
}
}
All we need to do is reconcile them in the following pattern: quasis[0] → expression[0] → quasi[1] → etc.
This sounds like a job for a simple loop!
Because we’re not concerned with the value of the expression, we can apply a placeholder to help distinguish it from the rest of the HTML string. This is helpful when we run checks later in our HTML traverser.
We’ll copy the placeholder template that eslint-plugin-lit uses for their own placeholders: {{__Q:${i}__}}
.
Try writing the loop, you’ll know you’ve nailed it once the final test passes. Here’s howtemplateExpressionToHtml
function looks after completing it:
There’s nothing special about this syntax. It’s specific enough so that it shouldn’t clash with any of our JavaScript code. The unique identifier for our expression is just the expression’s position in the array.
Let’s go through this step-by-step:
value
is initialised with an empty string, which is used to build the HTML- We calculate the length of the
quasis
array, which will be used to inform our loop's iteration count. - We kick off our loop and we:
– Access the quasi by the current index
– Access the expression by the current index
– Append the current quasi to ourvalue
string
– And if anexpression
exists, we also append it tovalue
. - We return
value
which we can now use to parse into an HTML AST.
And to really hit the point home, let’s run through the loop for the following code snippet:
As a reminder, the node we’ll be given will look like this:
- We’ll use
value
to build the HTML - We’ll look at the length of
node.quasi.quasis
to determine our loop's iteration count. In this case we have two elements in the array. One for<div style="
and another for"></div>
. - Loop 1:
– Grab the first quasi,<div style="
and append it to ourvalue
string.
– Because an element exists in the first index of theexpressions
array, we'll create a placeholder forval
– At the end of the first loop iteration, ourvalue
looks like<div style="{{__Q:i__}}
- Loop 2:
– Grab the second quasi,"></div>
and append it to ourvalue
string.
– Because no element exists in the second index of theexpressions
array, we skip this step
– at the end of the second loop iteration, ourvalue
looks like<div style="{{__Q:i__}}"></div>
- We return our fully-formed
value
string, which is ready to be parsed into an HTML AST.
After implementing the templateExpressionToHtml
function, the entirety of test suite passes, and hopefully yours does too!
The series of explanations might feel like overkill. The concepts around quasis and expressions can be confusing, so we wanted to spend a little extra time on them.
Limitations of ESLint
By definition, we use static analysis tools like ESLint, to check our code without running it. We can use ESLint to flag code style problems with our code, like we’ve just done in this article.
What ESLint can’t do is run our code, and report errors on the outputs. As a result, ESLint can’t report a problem for the following code snippet:
This is because expressions we pass as values won’t be evaluated by ESLint.
Going further
We’ve covered and learned how to build more advanced ESLINT rules to enforce design system best practices.
In our next and last article, we’ll take things a little further. We’ll write a more complicated rule along with a handful of unit tests. We’ll implement an ESLint rule for an existing design system’s implementation of a Tooltip
component. This rule will be particularly useful, as it will ensure that the component is used in its intended way and it will avoid tricky accessibility issues that may exclude whole populations from being able to use the component
Originally published at https://backlight.dev and written by @AndricoKaroulla on January 14, 2022.