Storybook Snapshot Testing: Storyshots, Test Runner, and Story-Driven Tests

Storybook Snapshot Testing: Storyshots, Test Runner, and Story-Driven Tests

Storybook has always occupied an awkward position in the testing stack. It documents components beautifully and lets developers iterate on UI in isolation, but for a long time the bridge between "story exists" and "story is tested" was left to the developer to build. @storybook/addon-storyshots was that bridge — and it worked well enough that thousands of codebases still rely on it. Then Storybook released the test-runner, which replaced the Jest-based approach with a Playwright-based one that runs stories in a real browser.

This post covers both: how Storyshots works, how to set it up, what the output tells you, how it compares to Chromatic, and how to migrate to the test-runner when you're ready. It also covers play functions as embedded test specs and the CI story for running story-based tests at scale.


What Storyshots Does

@storybook/addon-storyshots is a Jest addon that imports all your stories and renders each one into a snapshot. The snapshot captures the rendered DOM (or React component tree, depending on your renderer) as a serialized string. On subsequent runs, the output is compared against the stored snapshot. If they differ, the test fails.

The key insight is that it reuses your stories as test cases. You write a story once, and Storyshots converts it into a regression test for free. Every story becomes a test that asserts: "this component renders to this output given these props." No separate test files. No duplicated fixture data.

The catch is that snapshots only catch structural changes. A button that moves 10px to the right, changes color, or uses a different font looks identical in a DOM snapshot — you'll catch none of it. That's not a bug in Storyshots; it's the boundary of what snapshot testing covers. Knowing that boundary determines where Storyshots belongs in your stack.


Setting Up Storyshots with Jest

Install the packages:

npm install --save-dev @storybook/addon-storyshots @storybook/react

If you're on Storybook 7 or 8, also install the React test renderer that matches your React version:

npm install --save-dev react-test-renderer

Create a test file — one file is all you need:

// src/storyshots.test.js
import initStoryshots from '@storybook/addon-storyshots';

initStoryshots();

That's it. Running jest will now find every story defined in your Storybook config directory and render each one as a snapshot test. The test names come from the story's component title and story name: Button/Primary, Button/Disabled, Form/Login, and so on.

Pointing at the right stories

By default, Storyshots uses the configPath from your Storybook config, which it finds by looking for a .storybook directory in the project root. If your setup is non-standard, pass the path explicitly:

initStoryshots({
  configPath: '.storybook',
  storyKindRegex: /^(?!.*?DontTest).*$/,
});

storyKindRegex is useful for excluding stories you don't want snapshotted — documentation-only stories, playground stories, or any story that renders non-deterministically.

Custom serializers

By default, snapshots serialize to JSX using jest-snapshot. If you're testing components that include styled-components, emotion, or Material UI, the class names in the snapshot will include hashes that change across builds, breaking snapshots on every CSS modification. Fix this by swapping in a serializer:

npm install --save-dev jest-serializer-html
# or for styled-components:
npm install --save-dev jest-styled-components

Then in your Jest config:

{
  "snapshotSerializers": ["jest-styled-components"]
}

For emotion or CSS modules, @emotion/jest provides createSerializer that strips volatile class names while keeping the structure readable.


Snapshot Output and What to Check

A generated snapshot looks like this:

exports[`Button Primary 1`] = `
<button
  className="btn btn--primary"
  onClick={[Function]}
  type="button"
>
  Click me
</button>
`;

What this catches:

  • Element type changes (button becomes div)
  • Added or removed attributes (disabled, aria-label, data-testid)
  • Structural changes (a wrapper div added, a child element removed)
  • Text content changes

What this misses:

  • Visual changes that don't alter the DOM (colors, spacing, fonts via CSS)
  • Changes inside third-party components that render opaquely
  • Dynamic content that varies by timestamp, random ID, or animation state

When a snapshot test fails, git diff is your first tool. If the diff shows a meaningful structural change you didn't intend, that's a real regression. If it shows a change you did intend, update the snapshots with jest --updateSnapshot and commit the new snapshot files alongside the code change.

Snapshot files belong in version control. That's non-negotiable. They're the ground truth your CI system compares against, and reviewers need to see what changed in a PR.


Storyshots vs Chromatic: Pixel Diff vs DOM Snapshot

Chromatic is a cloud service (built by the Storybook team) that captures pixel-level screenshots of your stories and diffs them between commits. It catches visual regressions that DOM snapshots cannot — color changes, font rendering differences, layout shifts, image swaps.

The tradeoff is real:

Dimension Storyshots Chromatic
What it catches DOM structure Pixel-level appearance
Speed Fast (in-process Jest) Slower (screenshot per story)
Infrastructure None (runs locally and in CI) Cloud service required
Cost Free Paid above free tier
Noise Low (structural changes only) Higher (antialiasing, font rendering)
CI integration Jest output Dashboard review workflow

Chromatic's screenshot diff workflow works differently: it doesn't fail the CI build automatically. Instead, it flags changes for human review in its dashboard. You approve or reject the change, which sets the new baseline. This is deliberate — visual changes are often intentional, and requiring human sign-off on every visual change prevents false negatives.

The practical answer for most teams: run both. Storyshots gives you fast structural regression detection in your existing Jest suite. Chromatic (or an open-source alternative like Loki or Percy) catches visual regressions. They are not substitutes for each other.


Migrating to @storybook/test-runner

Storybook's test-runner is the current recommended approach for story-based testing. It replaced Storyshots as the primary testing strategy in Storybook 7. The test-runner runs stories in a real Chromium browser via Playwright, executes any play functions defined in the story, and reports failures as test results.

Install it:

npm install --save-dev @storybook/test-runner

Add a script to package.json:

{
  "scripts": {
    "test-storybook": "test-storybook"
  }
}

Run it while Storybook is serving:

npm run storybook &
npx wait-on tcp:6006
npm run test-storybook

The test-runner connects to the running Storybook dev server (or a static build — more on that below) and visits each story URL. It renders the story in Chromium, waits for it to stabilize, and runs the story's play function if one exists. If rendering throws an error or the play function throws, the test fails.

Why prefer the test-runner over Storyshots

  • Real browser rendering. Component behavior that depends on the browser's layout engine, CSS, or Web APIs is tested correctly. In-process Jest rendering with jsdom misses a wide class of bugs.
  • play function execution. Storyshots doesn't run play functions. The test-runner does. This is the bigger difference.
  • No snapshot files to maintain. The test-runner doesn't write snapshot files. Failures report the error that was thrown, not a diff against a stored string.
  • Active maintenance. Storyshots is deprecated. It still works, but Storybook's team is not developing it further.

The migration path is straightforward: delete storyshots.test.js, install the test-runner, and update your CI scripts.


Play Functions as Test Specs

The play function is where the test-runner really earns its keep. It lets you write interaction logic directly in the story, co-located with the component props and decorator setup.

A story with a play function in Storybook 7/8:

// Button.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  component: Button,
};
export default meta;

type Story = StoryObj<typeof Button>;

export const ClickTracking: Story = {
  args: {
    label: 'Submit',
    onClick: fn(),
  },
  play: async ({ args, canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button', { name: 'Submit' });

    await userEvent.click(button);

    await expect(args.onClick).toHaveBeenCalledTimes(1);
  },
};

When the test-runner visits this story, it executes play after rendering. If args.onClick wasn't called, the expect throws, and the test fails with a clear message.

For form stories:

export const FormSubmission: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await userEvent.type(
      canvas.getByLabelText('Email'),
      'user@example.com'
    );
    await userEvent.type(
      canvas.getByLabelText('Password'),
      'secret123'
    );
    await userEvent.click(canvas.getByRole('button', { name: 'Log in' }));

    await expect(
      canvas.getByText('Welcome back')
    ).toBeInTheDocument();
  },
};

This story now serves double duty: it documents the login form in the Storybook UI and tests that the submission flow works correctly. The play function runs in the Storybook canvas, so you can watch it execute in the browser when developing.

Assertions use Testing Library

@storybook/test re-exports from @testing-library/jest-dom and @testing-library/user-event. If you're already using Testing Library in your Jest suite, the queries (getByRole, getByLabelText, findByText) are identical. There's no new API to learn.


Running Stories in CI

For CI, build Storybook to a static site first, then point the test-runner at it. This avoids starting a dev server in CI and makes the run reproducible:

# .github/workflows/test.yml
- name: Build Storybook
  run: npm run build-storybook -- --quiet

- name: Run Storybook tests
  run: |
    npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
      "npx http-server storybook-static --port 6006 --silent" \
      "npx wait-on tcp:6006 && npm run test-storybook"

The test-runner supports --url to point at any running instance:

test-storybook --url http://localhost:6006

For parallelism, --shard splits the story list across multiple CI jobs:

# Job 1 of 3
test-storybook --shard 1/3

# Job 2 of 3
test-storybook --shard 2/3

The test-runner outputs JUnit XML with --junit:

test-storybook --junit

This integrates with test result reporting in GitHub Actions, CircleCI, and most other CI platforms.


Snapshot Update Workflow

Whether you're using Storyshots or the test-runner, you need a clear process for updating test expectations when component output intentionally changes.

For Storyshots (Jest snapshots):

# Update all snapshots
jest --updateSnapshot

# Update snapshots for a specific test file
jest --updateSnapshot src/components/Button.stories.js

# Review what changed before committing
git diff __snapshots__/

Make it a rule: snapshot updates go in the same commit as the component change that caused them. A snapshot update in a separate commit is a signal that the developer updated snapshots without understanding why they changed. PR reviewers should check both the component diff and the snapshot diff in every PR that touches components.

For the test-runner:

The test-runner doesn't use snapshot files, so there's nothing to update. If a play function assertion fails, you fix the component or update the assertion. If a story fails because the component throws during render, you fix the component. The failure message tells you what went wrong, not what the old output looked like.

Keeping stories and tests in sync:

The main drift risk is stories that become stale — they pass because they render without throwing, but the play function no longer exercises the actual behavior. Guard against this by:

  1. Requiring play functions for any story that documents interactive behavior
  2. Running the test-runner in CI on every PR (not just nightly)
  3. Treating a story that passes without a play function as a documentation-only story, not a test
  4. Using storyKindRegex or story tags to explicitly separate documentation stories from tested stories

Key Takeaways

Storyshots converts stories into Jest snapshot tests with zero extra code. It catches structural regressions — DOM changes you didn't intend. It misses visual changes. It's deprecated but still functional for teams not ready to migrate.

The test-runner is the current recommendation. It runs stories in a real browser, executes play functions, and doesn't require snapshot file management. Migration from Storyshots is a one-time afternoon task.

play functions make stories testable. A story with a play function is a test spec. It runs in the browser during development and in CI via the test-runner. No test file required beyond the story itself.

Chromatic and snapshot testing are not substitutes. DOM snapshots catch structure. Pixel diffs catch appearance. Run both if visual regression matters to your product.

Snapshot files are code. They go in version control, they get reviewed in PRs, and they get updated in the same commit as the component change that caused them. Any process that treats snapshot updates as noise to be dismissed is a process that will miss real regressions.

CI integration is straightforward. Build Storybook to a static site, serve it, run the test-runner against it. Add --shard for parallelism, --junit for test result reporting. The test-runner supports all the output formats your CI platform expects.

The shift from Storyshots to the test-runner is primarily about what you can test, not how you run the tests. Storyshots told you whether your component rendered to the same DOM. The test-runner tells you whether your component behaves correctly — and that's a much more valuable guarantee.

Read more

Start now free