Visual testing: The greatest trick in UI development
Get more confidence with less maintenance
In UI development, making sure everything looks right is as important as making sure it works. Visual tests are image snapshot tests that solve for this.
However, somewhat surprisingly, they can also replace the most brittle part of many UI unit tests: asserting on details of the UI. In many cases this can replace the unit test entirely, allowing you to test more with far less code.
This post covers:
- What are visual tests?
- How are they better than unit tests for UI?
- Why should I bring them into my workflow?
- What is the best way to visual test my components?
If you’re still unit testing your components, read on to learn a better way to develop UI.
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:
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 the 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:
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:
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:
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.
Test UX, not implementation details
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.
Visual Testing in Storybook
At Storybook, we believe so strongly in visual testing that we've included it as a first-class feature. Storybook's Visual Test Addon is backed by Chromatic, 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.
Chromatic provides PR checks to indicate when there are 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:
- Initiate visual tests
- Filter the sidebar to highlight visual differences
- Review and address those changes inside Storybook
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:
- Full-screen review mode to accept & deny changes.
- The ability to scope tests to the currently visible story or component.
- 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.