Over the years, as modern front-end frameworks have gained popularity, our testing approaches and libraries have also become more standardized. When evaluating testing libraries—especially for design systems or component libraries—we need to go beyond surface-level checks and focus on thoroughly testing the implementation of individual components.
Usually the common approaches for testing design systems on the web include the following:
This setup is solid, but it has some weak points—mainly the limitations of jsdom and the need to maintain multiple environments for testing similar component functionality. For example, if you have a Button with a disabled state, you would need to test it visually in Storybook and then write a separate unit test for the same state.
This situation was frustrating for me because of the amount of duplicate code I had to write and the added complexity of managing multiple testing environments. What I really wanted was a way to write component tests once and have them cover everything—without needing separate setups for different types of tests.
I started by trying out the Vitest experimental browser mode instead of Jest. While still in development, it proved to be quite stable in practice. Since it renders components in a headless browser, it provides direct access to real browser APIs, eliminating the need for mocking. Performance-wise, it's slightly slower but still comparable to running a Jest test suite—making it a worthwhile trade-off given the reduced need for mocks. It also includes a built-in debugging interface, which is a nice bonus. However, it still introduces another environment for rendering components, adding some complexity.
Since I was already using Storybook with Chromatic, I wanted a similar approach where I could run my unit tests within the same environment. Storybook has made significant investments in testing over the past few years, so it felt like perfect timing when I discovered their early access release of @storybook/experimental-addon-test. This package seamlessly integrates with Vitest and provides additional controls for running tests directly in the browser.
While both Vitest browser mode and the Storybook addon are still experimental, I decided to explore what migrating to them would look like. As a library author, I have full control over when to upgrade dependencies, allowing me to adapt to any API changes as they evolve even when packages are still experimental.
My ultimate goal after testing was to see if I can completely drop jest and react-testing-library dependencies. Storybook already provides sufficient Vitest bindings and integrations with other packages, allowing me to mock handlers, trigger user events, and handle async operations — all within the same environment.
After experimenting with multiple approaches to writing and structuring tests, I successfully migrated ~70 components to rely entirely on Storybook for testing.
With the Storybook — Vitest integration and turning stories into a complete test suite, there are a few additional benefits we're getting:
My initial idea was to have two Storybook files for each component—one for stories requiring visual testing in Chromatic and another dedicated to behavior testing. This approach seemed beneficial for controlling the number of screenshots taken while also maintaining a structure similar to traditional unit tests.
At the same time, this approach felt like over-optimization at the cost of developer experience. So, I decided it was fine to take more screenshots and store everything in a single file. By naming the stories to reflect the props they're testing, I could ensure full coverage of props and edge cases while keeping the setup simple. Additionally, maintaining a single file made it easier to manage tests without having to constantly decide where each test should go.
// Button.stories.tsx // Using the name field in my stories to mention multiple props // and keep correct casing for them export const variants = { name: 'variant', ... }; export const disabled = { name: 'disabled', ... }; export const attributes = { name: 'className, attributes', ... };
One key difference from writing unit tests in Jest or Vitest is that, since you're working in the browser, you don't immediately see what the test is doing unless you look at the code. Just like I previously used a custom example wrapper for screenshot tests, I found it equally useful for these new test cases—especially when displaying multiple examples within the same story. This made tests more visual and easier to understand at a glance.
With all the changes applied, let's take a look at how before and after state for a simplified set of Button component tests would look like. We'll test the button disabled state for its variants. In the original setup it would be spread across multiple tools:
// Button.stories.tsx import Button from "components/Button"; export default { title: "Components/Button", component: Button, }; export const disabled = () => ( <Example> <Example.Item title="variant: solid, disabled"> <Button disabled>Label</Button> </Example.Item> <Example.Item title="variant: outline, disabled"> <Button disabled variant="outline"> Label </Button> </Example.Item> </Example> ); // Button.test.tsx import { render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; describe("Components/Button", () => { test("disables the button", async () => { const handleClick = jest.fn(); render( <Button disabled onClick={handleClick}> Label </Button>, ); const button = screen.getByRole("button"); await userEvent.click(button); expect(button).toBeDisabled(); expect(handleClick).not.toHaveBeenCalled(); }); });
After the migration, this has changed to be a single test, even though the Storybook part became slightly more verbose:
// Button.stories.tsx import { StoryObj } from "@storybook/react"; import { expect, userEvent, fn, Mock } from "@storybook/test"; import Button from "components/Button"; export default { title: "Components/Button", component: Button, }; export const disabled: StoryObj<{ handleClick: Mock }> = { name: 'disabled', args: { handleClick: fn(), }, render: (args) => ( <Example> <Example.Item title="variant: solid, disabled"> <Button disabled onClick={args.handleClick}>Label</Button> </Example.Item> <Example.Item title="variant: outline, disabled"> <Button disabled variant="outline">Label</Button> </Example.Item> </Example> ), play: async ({ canvas, args }) => { const button = canvas.getAllByRole("button")[0]; await userEvent.click(button); expect(button).toBeDisabled(); expect(handleClick).not.toHaveBeenCalled(); } };
This single file will render a story in the browser and run the interactive behavior on mount for the first button as well as check the overall accessibility of the story. It will be picked up by Chromatic and you'll see the labels coming from Example.Item on the screenshots to know what is the expected result for each example. And the same story will be picked up by the Vitest when running your tests in CI 🔥
While migrating all components to the new setup, there were a few things I had to figure out:
I've chatted with the team behind Chromatic and Storybook, and they mentioned that challenges around structuring stories aren't unique to my setup. There's already an open RFC aimed at improving the developer experience when combining visual stories with unit tests, which is promising for the future.
Nevertheless I'm quite happy with how it turned out even though I wasn't sure about this approach at first. If you want to check how it all looks in practice, it's now available with the latest Reshaped release at reshaped.so/storybook. Note that testing module is only rendered when running Storybook locally and you will see a mix of multiple approaches, while I'm moving components to the final single-file approach described in the article.
I would also like to give a shout-out to:
Everything written here is based on my own experience and is not affiliated with the tools mentioned. In case you're also a design system enthusiast and have more ideas on how to improve testing setup — drop me a message.