“Unit testing”, crossed out; “Visual testing” below that

Visual testing is the greatest trick in UI development

Kyle Gach
Storybook

--

Get more confidence with less maintenance

In UI development, making sure everything looks perfect is just as important as making sure it works. While unit tests can check if each part functions correctly, they often miss visual issues. That’s where visual tests come in, yielding more useful results with less code.

This post will cover:

If you’re still unit testing your components, read on to learn a better way to develop UI.

Illustration of a simplified Storybook on the left, labeled “Write code”. There’s an arrow pointing right labeled “Detect bugs”. On the right is that same simplified Storybook with highlighted stories in the sidebar, labeled “Visual test”. There’s an arrow pointing left labeled “Fix”.

Visual Testing 101

Before we jump into why Visual Testing is so great, what is it and how does it work?

A visual test is a snapshot test that compares image snapshots of a UI component before and after a code change. The test fails if the snapshots do not match.

  • Either the difference is expected and the baseline (before) image must be updated
  • Or the difference is unexpected and the user should go fix the code.

Here’s how that process looks in practice:

A workflow diagram with 5 steps. 1) Baseline; Create a story and save a snapshot as the baseline. 2) Update; Update your component code. 3) Run visual test; Take a snapshot of your changes and compare with the baseline. 4) Accept or deny; Approve, if the change is intentional, or deny the change. 5) New baseline; When the test is accepted, the baseline is updated.

Less code, better tests

Visual testing is pretty neat, but why do we think it’s a fundamentally better way to test UI? The short answer is that visual tests are easier to write and maintain than unit tests. At they same time, they provide more confidence because they test more.

Consider a simple example using React Testing Library (RTL), the most popular way to unit test components in test runners like Jest and Vitest.

// Button.test.js
import { render, screen } from '@testing-library/react';
import Button from './Button';

it('uses custom text for the button label', () => {
render(<Button>Click me!</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me!');
})

This test mounts the Button component, then asserts the text contents of the button label. Tools like Playwright CT and Cypress CT also use similar syntax and constructs.

Storybook’s syntax is slightly different, but same idea. Here’s an equivalent to the RTL example:

// Button.stories.js
import Button from './Button';
export default { component: Button };

export const CustomText = {
args: { children: 'Click me!' },
play: async ({ canvasElement }) => {
await expect(canvasElement).toHaveTextContent('Click me!')
},
};

And here’s what that looks like inside Storybook:

Screenshot of Storybook, showing the example Button component story and its passing test

With tests like these, we’re asserting on exactly one thing: the text of the button.

Visual tests not only assert that the button contains the correct text, but also that the button is blue, has rounded corners, renders with the same font, and so forth. And they do that without writing a single explicit assertion.

Here’s how simple that test becomes, with the Visual Tests addon:

export const CustomText = {
args: { children: 'Click me!' },
};

In the following example, I’ve accidentally introduced a bug in the global CSS that strips much of the Button’s styling. This would pass RTL’s functional test, but our visual tests catch the difference and display it as a change:

Screenshot of Storybook showing the example Button story and its failing visual test

Real world example

Saving one line of assertion might not seem like a big deal, but in a real world project the benefits quickly add up. Consider a component like Mealdrop’s shopping cart:

Screenshot of Storybook showing the ShoppingCartMenu component’s “With Items” story

Functionally, we want to test that all of the items in the shopping cart display correctly, and that the checkout button is enabled because there are items in the cart.

With a visual test, we can test this with story WithItems that sets up the shopping cart with its inputs but doesn’t actually contain any explicit test logic:

// ShoppingCartMenu.stories.js
import { ShoppingCartMenu } from './ShoppingCartMenu'

export default { component: ShoppingCartMenu };

export const WithItems = {
args: {
cartItems: [ /* items */ ],
totalPrice: 1200
},
}

If we don’t trust that the enabled button will render differently in the UI, we can extend that test to define WithItemsEnabled, which specifically verifies that the button is not disabled:

// ShoppingCartMenu.stories.js

export const WithItemsEnabled = {
...WithItems,
play: async ({ canvasElement }) => {
const checkout = await findByRole(canvasElement, 'button');
await expect(checkout).not.toBeDisabled();
},
}

Now imagine writing the same test in RTL alone. We’d want to test that each item in the shopping cart appears with the correct amount, that the total is correct, and so on.

// ShoppingCartMenu.test.js

it('renders correctly with items', () => {
render(<ShoppingCartMenu cartItems={[ /* items */ ]} totalPrice={1200} />);

const fries = await screen.findByText(/^Fries$/);
expect(getByText(fries.parentElement, '€2.50')).toBeInTheDocument();
// More assertions here

const cheeseburger = await screen.findByText(/^Cheeseburger$/);
expect(getByText(cheeseburger.parentElement, '€8.50')).toBeInTheDocument();
// More assertions here

/*
*
*
* Dozens of lines omitted here,
* for everybody's sanity.
*
*
*/

const checkout = screen.getByRole('button');
expect(checkout).not.toBeDisabled();
});

Of course we could shorten all this with a helper function to check each cart item, but when we need to write and maintain helper functions for tests like this, we’ve already lost.

Now multiply this single test out across your entire application, which might contain hundreds of components of various complexities. It’s a nightmare to maintain these kinds of tests.

In contrast, writing stories for hundreds of components and visually testing them is achievable, and the best frontend teams in the world are already doing this.

Stripping away the concrete

Testing guru Cory House recently commented on somebody’s opinion that “Automated tests are like pouring concrete on code.” The RTL code in the previous section is exactly the “concrete” that people complain about in automated testing.

To avoid concrete, Cory recommends to “Test the UX, not implementation details”. And testing UX is exactly what visual testing gives us. What’s more, visual snapshots are far easier to maintain than code: as we’ve seen above, updating a test is as easy as pressing a button to accept a new baseline snapshot when your story renders in the desired state.

And since Storybook also supports RTL actions and queries, you have as much power as you need to test at whatever level of detail is needed to get confidence in your code.

Storybook is powered by world-class test infrastructure

We believe so strongly in visual testing that we built the world’s best visual testing infrastructure. Chromatic identifies changes by comparing image snapshots before and after a code update and highlights the differences for review.

It runs thousands of tests in parallel in the cloud, in tens of seconds, across multiple browsers (Chrome, Safari, Firefox, Edge), viewports, themes, and i18n locales.

Workflow diagram with three steps. 1) Push code. 2) Detect UI changes. 3) Get PR checks

visual changes associated with a PR. When tests fail, the user can click through to an efficient UI for reviewing the visual changes. Until now, PR checks have been the primary workflow for using Chromatic and other, similar visual testing services.

Storybook’s Visual Tests Addon is a new and innovative twist on this workflow, putting the power of Chromatic inside Storybook itself. This lets you run visual tests on demand as you develop, without needing to push code, run CI, and wait on a bunch of unrelated checks.

This is an amazing workflow. From within your component workshop, it’s now possible to:

  1. Initiate visual tests
  2. Filter the sidebar to highlight visual differences
  3. Review and address those changes inside Storybook
Screenshot of a Storybook showing running visual tests and the highlighted test failures in the sidebar

The Visual Tests Addon makes it faster than ever to catch UI bugs and stay in flow as you build your components. We believe it is major step towards the “holy grail” for UI development.

Try it today

Storybook’s Visual Test Addon is included in new Storybook installations:

npx storybook@latest init

And if you’re upgrading from an older version of Storybook you’ll now be prompted to choose if you’d like to install the addon to your existing project:

npx storybook@latest upgrade

What’s next?

The Visual Tests Addon is stable and available in Storybook 8 today. We are considering the following enhancements:

  1. Full-screen review mode to accept & deny changes.
  2. The ability to scope tests to the currently visible story or component.
  3. An always-on “watch mode” that runs functional tests locally on your dev machine and complements visual testing with a faster feedback loop.

For an overview of projects we’re considering and actively working on, please check out Storybook’s roadmap.

--

--

Kyle Gach
Storybook

Rhymes with “batch”. Always learning. Trying to be more kind than nice. ¶ DX & Community at Chromatic/Storybook. ¶ he/him ⛰ 🚲 🍺 📚 🥃