In our 2019 Design Systems Survey, only 20% of respondents stated they have implemented unit testing on a design system. A common sentiment from teams we work with is that while they recognize the value of testing their design systems, they are unsure how to get started writing valuable tests. Let’s explore a few approaches to unit testing in a design system that we’ve found helpful for increasing quality and confidence in the final product.
Design Systems vs Applications
While there are many similarities in testing approaches between design systems and applications, it’s important to be aware of the differences between them. The goals of each are quite different, and our testing approach should reflect that. Let’s consider a few of those differences.
Applications
The goals of an application can widely vary depending on the type of application, but they are determined by the type of value that the application intends to provide. For this reason, application testing is often focused on business logic, specific user flows, and integration of the complete system.
Design Systems
The goals of a design system tend to be more universal, and they are centered around building a cohesive user experience across an application (or set of applications). For these reasons, design system testing is focused on UI behavior, layout/rendering logic, and visual aspects.
Oftentimes, you’ll find that the frontend of an application requires testing of UI behavior and rendering as well. However, if a design system is well tested, we eliminate the need for more granular testing in these areas at the application level. In this sense, your design system tests become another level of unit testing for the products that consume the design system.
By focusing design system tests on these goals of layout, rendering, UI behavior, and visual aspects, we allow consumers of the design system to focus on testing the things that make the application special.
Getting Started
First, let’s talk about tooling. Which test runner combination best suits unit testing for design systems? Let’s consider some things that are important to test in a design system:
HTML structure
Template rendering logic
UI Behavior
At Sparkbox, we are fond of using Mocha (with Chai) for unit testing. Mocha is mature, highly configurable, and fast. However, it also takes more work up front to configure. Jest, on the other hand, requires almost no configuration and ships out of the box with several features that are useful for design system testing:
JSDOM emulates the browser’s DOM in a Node.js environment. This allows us to test code that relies on browser-based APIs, such as
document.querySelector
.Snapshot testing helps to ensure that the structure of your HTML remains correct (for example, refactoring a component’s logic while ensuring the HTML output doesn’t change unexpectedly).
For the examples in this article, we’ll be using Jest, but the syntax and concepts in Jest are transferable to the test runner of your choice.
What Should I Test?
Think about your testing approach from a holistic perspective. Different types of tests have different purposes, which require different levels of granularity. Generally, higher-level integration tests provide more overall value because they exercise more of the application and demonstrate that things are working cohesively. However, these tests come at a higher cost because they tend to be slower to run and may require more maintenance. By contrast, lower-level unit tests are typically faster to run and less brittle, but they may not provide as much value since they only test isolated pieces of your system. The Test Pyramid is a technique to visualize this cost-value relationship.
Unit Tests (Inputs and Outputs)
At the bottom of our design system test pyramid, we have different types of unit tests. These include testing JavaScript logic, template rendering, and Sass mixins. We’ve covered unit testing your Sass before, so here we’ll focus on testing JavaScript and templates.
Testing JavaScript
Pure functions are among the most straightforward things to test. You can think of these tests in terms of inputs and outputs:
These properties of a pure function make it easy to test:
The same input always returns the same output
No state or behavior is modified outside of the function
Formatting logic and layout calculations are a few examples of pure functions that may exist in a design system. For example, imagine that we have a formatting function that is responsible for capitalizing a name:
capitalizeName('egon spangler') // returns "Egon Spangler"
A test for this function might look like:
describe('capitalizeName', () => {
it('capitalizes a name', () => {
// arrange the test input
const name = 'egon spangler';
// act on the test input
const output = capitalizeName(name);
// assert that the output is correct
expect(output).toEqual('Egon Spangler');
});
});
Testing Templates
In addition to JavaScript functionality, we can think of rendering templates or components as pure functions:
Snapshot tests are useful here, but consider using them sparingly. Over-reliance on snapshot tests tends to create noisy and abundant test failures when the structure of your HTML changes. As a rule of thumb, I would recommend having a single snapshot test for each template in order to ensure the general structure of the HTML doesn’t change unexpectedly. Consider more targeted tests for testing conditional logic in your templates or testing properties that are rendering correctly. For example, testing that a tooltip renders a message in the appropriate location:
describe("Tooltip template", () => {
it("renders the message", () => {
const tooltip = Tooltip({ message: "hi!" });
const message = tooltip.querySelector(".tooltip-message");
expect(message.textContent).toBe("hi!");
});
});
The above test is only concerned that the tooltip message is rendered specifically inside of the element with the class .tooltip-message
, which likely has important styling tied to it. These types of targeted tests are less likely to fail when an unrelated part of the component is changed. For example, changing the containing element from a div
to a span
would likely cause a snapshot test to fail even though that detail is not important to the rendering of the message.
Behavioral Tests (Actions and Effects)
Moving up to the next rung of the test pyramid, let’s take a look at behavioral tests. While unit tests can be thought of in terms of inputs and outputs, behavioral tests can be better understood in terms of actions and effects:
These tests can be framed from a user’s perspective, describing the action of the user and the desired effect of that action. For example:
A behavioral test for our tooltip might look like this:
it("clicking the handle toggles the message visibility", () => {
// attach the tooltip to our test DOM
document.body.appendChild(Tooltip({ message: "hi!" }));
const message = document.querySelector(".tooltip-message");
const event = new window.Event("click", { bubbles: true });
// dispatch a click event and assert that the tooltip is shown
document.querySelector(".tooltip-handle").dispatchEvent(event);
expect(message.classList.contains("visible")).toBe(true);
// dispatch a click event and assert that the tooltip is hidden
document.querySelector(".tooltip-handle").dispatchEvent(event);
expect(message.classList.contains("visible")).toBe(false);
});
Above, we are testing the behavior of the component when we interact with it. This test asserts that the action of clicking the tooltip handle has the effect of showing or hiding the tooltip.
These kinds of tests are valuable for a design system because they ensure that the DOM is updated correctly. However, the cost of these tests is that they are slower to run because they require mounting and updating a virtual instance of the DOM. It may not be noticeable at first, but as your test suite grows, this can slow down your feedback loop. This is why it’s important to test at the appropriate level. For example, if there is complex logic around whether or not an element should display, consider extracting that logic into a single function that returns a boolean value and then testing those logical conditions in a set of unit tests directly against that function. By testing at the appropriate level, we can reduce the overall number of behavioral tests needed and avoid slowing down our test suite.
Moving Forward
Testing your design system helps you ensure confident delivery and consumption of the final product. Consider how your design system might benefit from the approaches described here and how different types of tests might fit into the structure of your design system. For more information on design systems, remember to check out our 2019 Design Systems Survey. To explore these ideas further, check out a working example of the concepts above, both in plain JavaScript and React.
Sparkbox’s Development Capabilities Assessment
Struggle to deliver quality software sustainably for the business? Give your development organization research-backed direction on improving practices. Simply answer a few questions to generate a customized, confidential report addressing your challenges.