[React] Building VRT + Accessibility Testing with Storybook Test Runner

2026-03-06 hit count image

Sharing how to build VRT (Visual Regression Testing) and accessibility testing for React components using Storybook Test Runner and jest-image-snapshot.

react

Overview

While reviewing testing strategies for shared components, we built a VRT (Visual Regression Testing) and accessibility testing environment using Storybook’s Test Runner.

This article documents the process of validating Storybook-based testing as part of establishing a testing strategy for shared components. For the overall testing strategy and final tool selection, see [React] Testing Strategy for Shared Components in a Monorepo.

Storybook Test Runner renders Stories registered in Storybook in a real browser (Playwright) and can perform screenshot capture and accessibility checks in the postVisit hook.

Overall Architecture

The workflow of Storybook Test Runner-based VRT is as follows:

  1. Build Storybook and run it as a static server
  2. Test Runner launches a Playwright browser and visits each Story
  3. preVisit hook injects axe-core into the page
  4. When a Story renders, the play function executes (event testing)
  5. postVisit hook captures a screenshot and compares it with jest-image-snapshot
  6. Accessibility test Stories (IDs containing a-11-y) are checked with axe-playwright
Storybook Build → Static Server (port 6006)
  → Test Runner (Playwright)
    → preVisit: inject axe-core
    → Story rendering + play function execution
    → postVisit: screenshot capture + snapshot comparison + accessibility check

Environment Setup

To set up Storybook Test Runner-based VRT + accessibility testing, you first need to configure the test environment.

Package Installation

Install the required packages for Storybook Test Runner and VRT/accessibility testing:

yarn add -D @storybook/test-runner playwright jest-image-snapshot @types/jest-image-snapshot axe-playwright

The role of each package is as follows:

PackageRole
@storybook/test-runnerRuns Storybook Stories as tests in a Playwright browser
playwrightHeadless browser automation (Test Runner runtime)
jest-image-snapshotCompares screenshot images against existing snapshots
axe-playwrightPerforms accessibility (a11y) checks on Playwright pages

Test Runner Configuration

This is the core file of this setup. Configure the postVisit hook in .storybook/test-runner.ts to perform screenshot capture and accessibility checks after Story rendering.

// .storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
import { injectAxe, checkA11y } from 'axe-playwright';
import path from 'path';

const snapshotsDir = path.resolve(__dirname, '../__image_snapshots__');

const config: TestRunnerConfig = {
  setup() {
    // Register jest-image-snapshot custom matcher
    expect.extend({ toMatchImageSnapshot });
  },

  async preVisit(page) {
    // Inject axe-core into the page before each Story visit
    await injectAxe(page);
  },

  async postVisit(page, context) {
    // Only test Stories from test.stories.tsx files
    const storyIdParts = context.id.split('--');
    const componentPath = storyIdParts[0];

    if (!componentPath.endsWith('-test')) {
      return;
    }

    // Accessibility test: run when Story ID contains 'a-11-y'
    if (context.id.includes('a-11-y')) {
      try {
        await checkA11y(page, '#storybook-root', {
          detailedReport: true,
          detailedReportOptions: {
            html: true,
          },
        });
      } catch (error) {
        console.error('Accessibility violations detected:', error);
      }
    }

    // Wait for CSS, fonts, and animations to stabilize
    await page.waitForTimeout(1000);

    // Wait for async rendering such as list items
    await page
      .waitForSelector('#storybook-root ul li', { timeout: 5000 })
      .catch(() => {
        // Some Stories may not have list items (empty state, error states, etc.)
      });

    // Capture screenshot
    const image = await page.screenshot({
      fullPage: false,
      animations: 'disabled',
    });

    // Compare with existing snapshot
    expect(image).toMatchImageSnapshot({
      customSnapshotsDir: snapshotsDir,
      customSnapshotIdentifier: context.id,
      failureThreshold: 0.001,
      failureThresholdType: 'percent',
      storeReceivedOnFailure: true,
      updatePassedSnapshot: process.env.UPDATE_SNAPSHOTS === 'true',
    });
  },
};

export default config;

Key points:

  • setup(): Registers the jest-image-snapshot custom matcher
  • preVisit(): Injects axe-core into the page before each Story visit, enabling accessibility checks in postVisit
  • postVisit(): Performs screenshot capture, snapshot comparison, and accessibility checks after Story rendering
  • Test target filtering: Only tests Stories from *.test.stories.tsx files using componentPath.endsWith('-test'), so regular Stories are unaffected
  • Accessibility test trigger: Runs accessibility checks when the Story ID contains a-11-y
  • Snapshot update: Snapshots can be updated using the UPDATE_SNAPSHOTS=true environment variable

.gitignore Configuration

Baseline snapshots for VRT tests should be committed. However, diff and received images generated on test failure should be excluded from Git.

# Visual regression test outputs (exclude diff and received images, but keep baseline snapshots)
__diff_output__/
__received_output__/

Writing Test Stories

Now let’s write Stories for Storybook Test Runner-based VRT + accessibility testing.

File Convention

Test-dedicated Stories use the *.test.stories.tsx filename convention.

ComponentName/
├── index.tsx                  # Component implementation
├── index.stories.tsx          # Regular Stories (for design/implementation review)
└── sample.test.stories.tsx    # Test-dedicated Stories (VRT + event + accessibility)

Reasons for separating test Stories from regular Stories:

  • Regular Stories are used for design system documentation and development review
  • Test-dedicated Stories focus on rendering/event/accessibility testing
  • Since the Test Runner configuration filters only Stories ending with -test, screenshots are not generated for regular Stories

Rendering Tests (Verifying View with Props Changes)

Write separate Stories for each Props combination to verify the View through VRT.

// src/components/List/ExpandableTreeList/sample.test.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { ExpandableTreeList } from '.';

const meta: Meta<typeof ExpandableTreeList> = {
  component: ExpandableTreeList,
  parameters: {
    chromatic: { disableSnapshot: true },
  },
};
export default meta;
type Story = StoryObj<typeof meta>;

// Empty state rendering
export const RenderingEmptyState: Story = {
  name: 'Rendering: Empty State',
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList fetchChildNodesFunction={createMockFetchFunction()} />
    </div>
  ),
};

// Tree node display
export const RenderingTopTreeNodes: Story = {
  name: 'Rendering: Display Tree Nodes',
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList
        topTreeNodes={sampleTopTreeNodes}
        fetchChildNodesFunction={createMockFetchFunction()}
      />
    </div>
  ),
};

// Count label display
export const RenderingCountLabel: Story = {
  name: 'Rendering: Count Label Display',
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList
        topTreeNodes={sampleTopTreeNodes}
        countLabel="${count} items"
        fetchChildNodesFunction={createMockFetchFunction()}
      />
    </div>
  ),
};

// Remove button display (when onRemove prop is provided)
export const RenderingRemoveButtons: Story = {
  name: 'Rendering: Remove Buttons with onRemove',
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList
        topTreeNodes={sampleTopTreeNodes}
        fetchChildNodesFunction={createMockFetchFunction()}
        onRemove={fn()}
      />
    </div>
  ),
};

Each Story tests one Props combination, making it clear which Props produce which View.

Event Tests

You can simulate user interactions using Storybook’s play function. The state after play function execution is captured as a screenshot in the postVisit hook.

import { expect } from '@storybook/jest';
import { userEvent, within } from '@storybook/testing-library';

// Child node display test
export const EventDisplayChildNodes: Story = {
  name: 'Event: Display Child Nodes After Fetch',
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList
        topTreeNodes={sampleTopTreeNodes}
        fetchChildNodesFunction={createMockFetchFunction()}
      />
    </div>
  ),
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // Click expand button
    const expandButton = canvas.getAllByRole('button')[0];
    await userEvent.click(expandButton);

    // Wait for child node rendering
    await new Promise((resolve) => setTimeout(resolve, 100));

    // Verify child nodes are displayed
    expect(canvas.getByText('Child 1')).toBeInTheDocument();
    expect(canvas.getByText('Child 2')).toBeInTheDocument();
  },
};

// Fetch error display test
export const EventFetchError: Story = {
  name: 'Event: Fetch Error Display',
  render: () => {
    const errorFetch = async () => {
      throw new Error('Network error');
    };
    return (
      <div style={{ height: '25rem' }}>
        <ExpandableTreeList
          topTreeNodes={sampleTopTreeNodes}
          fetchChildNodesFunction={errorFetch}
        />
      </div>
    );
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    const expandButton = canvas.getAllByRole('button')[0];
    await userEvent.click(expandButton);

    await new Promise((resolve) => setTimeout(resolve, 100));

    // Verify error message is displayed
    expect(canvas.getByText('An error occurred')).toBeInTheDocument();
  },
};

// Props change node update test
export const RenderingPropsChange: Story = {
  name: 'Rendering: Node Update on Props Change',
  render: () => {
    const TestComponent = () => {
      const [nodes, setNodes] = useState(sampleTopTreeNodes);
      return (
        <div style={{ height: '25rem' }}>
          <button
            onClick={() =>
              setNodes([
                { value: 'new', label: 'New Node', hasChildren: false },
              ])
            }
          >
            Change Nodes
          </button>
          <ExpandableTreeList
            topTreeNodes={nodes}
            fetchChildNodesFunction={createMockFetchFunction()}
          />
        </div>
      );
    };
    return <TestComponent />;
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    expect(canvas.getByText('Node 1')).toBeInTheDocument();

    await userEvent.click(canvas.getByText('Change Nodes'));

    expect(canvas.queryByText('Node 1')).not.toBeInTheDocument();
    expect(canvas.getByText('New Node')).toBeInTheDocument();
  },
};

Accessibility Tests

You can also use axe-playwright in Storybook to perform accessibility checks. By including A11y in the Story name, the Story ID will contain a-11-y, and the Test Runner will automatically perform accessibility checks.

export const A11yTest: Story = {
  name: 'Accessibility: Accessibility Test',
  tags: ['a11y-test'],
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList
        topTreeNodes={sampleTopTreeNodes}
        sectionLabel="Accessibility Test"
        countLabel="${count} items"
        fetchChildNodesFunction={createMockFetchFunction()}
        onRemove={fn()}
      />
    </div>
  ),
  parameters: {
    chromatic: { disableSnapshot: true },
    a11y: {
      config: {
        rules: [
          {
            id: 'button-name',
            enabled: false, // TODO: Add aria-labels to expand/remove buttons
          },
        ],
      },
    },
  },
};

Accessibility checks are performed through axe-playwright’s checkA11y, and the test fails if WCAG violations are found.

Run Scripts

The Storybook Test Runner-based VRT tests configured above can be run with the following scripts:

// package.json
{
  "scripts": {
    "test:visual:ci": "yarn build-storybook && npx concurrently -k -s first -n \"Server,Test\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"yarn playwright install && npx wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\"",
    "test:visual:update": "UPDATE_SNAPSHOTS=true yarn build-storybook && npx concurrently -k -s first -n \"Server,Test\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"yarn playwright install && npx wait-on tcp:127.0.0.1:6006 && UPDATE_SNAPSHOTS=true test-storybook --url http://127.0.0.1:6006 --updateSnapshot\"",
    "test:a11y": "yarn build-storybook && npx concurrently -k -s first -n \"Server,Test\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"yarn playwright install && npx wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\""
  }
}

The execution flow is:

  1. yarn build-storybook: Build Storybook into static files
  2. http-server storybook-static --port 6006: Run the built Storybook as a local server
  3. wait-on tcp:127.0.0.1:6006: Wait until the server is ready
  4. test-storybook --url http://127.0.0.1:6006: Run the Test Runner

Script purposes:

  • test:visual:ci: Run VRT + accessibility tests in CI environment
  • test:visual:update: Update baseline snapshots (when components are intentionally changed)
  • test:a11y: Run accessibility tests

Directory Structure

The directory structure of files created or modified in this article:

Project Root/
├── .storybook/
│   ├── main.ts                    # Storybook config (existing file)
│   └── test-runner.ts             # Test Runner config (newly created)
├── __image_snapshots__/           # Baseline snapshot images (auto-generated, committed to Git)
│   ├── component-name-test--story-name.png
│   └── ...
├── __diff_output__/               # Diff images on test failure (.gitignore target)
├── __received_output__/           # Received images on test failure (.gitignore target)
├── .gitignore                     # Add diff/received directory exclusion (modified)
├── package.json                   # Add run scripts (modified)
└── src/
    └── components/
        └── List/
            └── ExpandableTreeList/
                ├── index.tsx
                ├── index.stories.tsx          # Regular Stories (existing file)
                └── sample.test.stories.tsx    # Test-dedicated Stories (newly created)

Summary

This article introduced how to build a VRT + accessibility testing environment using Storybook Test Runner.

ItemDetails
Rendering/VRTStorybook Test Runner + jest-image-snapshot
Event TestingSimulate interactions with Storybook play function
Accessibility TestingWCAG violation checks with axe-playwright
Test SeparationSeparate test Stories with *.test.stories.tsx file convention
Snapshot ManagementStore baseline images in __image_snapshots__/, update with UPDATE_SNAPSHOTS

The Storybook-based approach has the advantage of using Stories directly for testing without a separate rendering utility. However, it differs from product-level unit testing (Jest/Vitest) approaches, requiring the team to manage two testing patterns, and Story files can become unwieldy.

For those interested in building VRT with Jest + Puppeteer, see [React] Building VRT with Jest + Puppeteer.

Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!

App promotion

You can use the applications that are created by this blog writer Deku.
Deku created the applications with Flutter.

If you have interested, please try to download them for free.



SHARE
Twitter Facebook RSS