QA Engineer Skills 2026QA-2026Component-Level Visual Testing in Storybook

Component-Level Visual Testing in Storybook

Why Component Testing Catches Issues Earlier

Storybook enables visual testing at the component level, catching visual regressions before they compound into page-level issues. A button component tested in isolation across all its states (default, hover, focus, disabled, loading) catches styling bugs at the source -- not after they have propagated into 20 different pages.


Storybook + Accessibility Testing

Configure Storybook to run accessibility checks on every story:

// .storybook/preview.ts
import type { Preview } from '@storybook/react';

const preview: Preview = {
    parameters: {
        a11y: {
            // axe-core configuration for all stories
            config: {
                rules: [
                    { id: 'color-contrast', enabled: true },
                    { id: 'image-alt', enabled: true },
                    { id: 'label', enabled: true },
                    { id: 'button-name', enabled: true },
                ],
            },
        },
    },
};

export default preview;

This configuration adds an "Accessibility" panel to every story in Storybook. Developers see violations in real-time while building components.


Writing Stories for Visual Testing

DataTable Component

// DataTable.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { DataTable } from './DataTable';
import { within, userEvent } from '@storybook/testing-library';

const meta: Meta<typeof DataTable> = {
    title: 'Components/DataTable',
    component: DataTable,
    parameters: {
        chromatic: {
            viewports: [375, 768, 1200],
        },
    },
};
export default meta;

export const WithData: StoryObj<typeof DataTable> = {
    args: {
        columns: [
            { key: 'name', header: 'Product Name', sortable: true },
            { key: 'price', header: 'Price', sortable: true },
            { key: 'stock', header: 'In Stock', sortable: false },
        ],
        data: [
            { name: 'Widget A', price: '$29.99', stock: 'Yes' },
            { name: 'Widget B', price: '$49.99', stock: 'No' },
            { name: 'Widget C', price: '$19.99', stock: 'Yes' },
        ],
    },
};

export const Empty: StoryObj<typeof DataTable> = {
    args: {
        columns: [{ key: 'name', header: 'Product Name' }],
        data: [],
    },
};

export const Loading: StoryObj<typeof DataTable> = {
    args: {
        columns: [{ key: 'name', header: 'Product Name' }],
        data: [],
        loading: true,
    },
};

export const WithError: StoryObj<typeof DataTable> = {
    args: {
        columns: [{ key: 'name', header: 'Product Name' }],
        data: [],
        error: 'Failed to load product data. Please try again.',
    },
};

// Interaction testing: verify sort behavior
export const SortedByPrice: StoryObj<typeof DataTable> = {
    ...WithData,
    play: async ({ canvasElement }) => {
        const canvas = within(canvasElement);
        // Click the Price column header to sort
        await userEvent.click(canvas.getByText('Price'));
        // Visual snapshot captures the sorted state
    },
};

Testing All Component States

A visual regression suite for a single component should cover all meaningful states:

// Button.stories.tsx -- comprehensive state coverage
export const AllStates: StoryObj<typeof Button> = {
    render: () => (
        <div style={{ display: 'grid', gap: '1rem', maxWidth: '400px' }}>
            <h3>Primary</h3>
            <Button variant="primary">Default</Button>
            <Button variant="primary" disabled>Disabled</Button>
            <Button variant="primary" loading>Loading</Button>

            <h3>Secondary</h3>
            <Button variant="secondary">Default</Button>
            <Button variant="secondary" disabled>Disabled</Button>

            <h3>Danger</h3>
            <Button variant="danger">Default</Button>
            <Button variant="danger" disabled>Disabled</Button>

            <h3>Sizes</h3>
            <Button size="sm">Small</Button>
            <Button size="md">Medium</Button>
            <Button size="lg">Large</Button>

            <h3>With Icons</h3>
            <Button icon="plus">Add Item</Button>
            <Button icon="trash" variant="danger">Delete</Button>
        </div>
    ),
    parameters: {
        chromatic: {
            viewports: [375, 1200],
        },
    },
};

// Focus state -- important for accessibility visual testing
export const FocusState: StoryObj<typeof Button> = {
    args: { variant: 'primary', children: 'Focused Button' },
    play: async ({ canvasElement }) => {
        const canvas = within(canvasElement);
        const button = canvas.getByRole('button');
        await button.focus();
        // Snapshot captures the focus ring
    },
};

Form Component Accessibility Stories

// TextField.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { TextField } from './TextField';

const meta: Meta<typeof TextField> = {
    title: 'Components/TextField',
    component: TextField,
};
export default meta;

export const Default: StoryObj<typeof TextField> = {
    args: {
        label: 'Email address',
        placeholder: 'you@example.com',
        type: 'email',
    },
};

export const WithError: StoryObj<typeof TextField> = {
    args: {
        label: 'Email address',
        value: 'not-an-email',
        error: 'Please enter a valid email address',
    },
};

export const WithHelperText: StoryObj<typeof TextField> = {
    args: {
        label: 'Password',
        type: 'password',
        helperText: 'Must be at least 8 characters with one number',
    },
};

export const Required: StoryObj<typeof TextField> = {
    args: {
        label: 'Full Name',
        required: true,
    },
};

export const Disabled: StoryObj<typeof TextField> = {
    args: {
        label: 'Username',
        value: 'johndoe',
        disabled: true,
    },
};

Automated Storybook Testing in CI

With Chromatic

- name: Build and test Storybook
  run: |
    npx storybook build -o storybook-static
    npx chromatic --project-token=${{ secrets.CHROMATIC_TOKEN }} \
      --exit-once-uploaded \
      --only-changed

With Playwright

// tests/storybook/visual.spec.ts
import { test, expect } from '@playwright/test';

const STORYBOOK_URL = 'http://localhost:6006';

// Generate tests for each story
const stories = [
    'components-button--primary',
    'components-button--disabled',
    'components-button--loading',
    'components-datatable--with-data',
    'components-datatable--empty',
    'components-textfield--with-error',
];

for (const story of stories) {
    test(`visual: ${story}`, async ({ page }) => {
        await page.goto(`${STORYBOOK_URL}/iframe.html?id=${story}`);
        await page.waitForLoadState('networkidle');

        await expect(page).toHaveScreenshot(`${story}.png`, {
            maxDiffPixelRatio: 0.01,
            animations: 'disabled',
        });
    });

    test(`a11y: ${story}`, async ({ page }) => {
        await page.goto(`${STORYBOOK_URL}/iframe.html?id=${story}`);
        await page.waitForLoadState('networkidle');

        const AxeBuilder = (await import('@axe-core/playwright')).default;
        const results = await new AxeBuilder({ page }).analyze();
        expect(results.violations).toEqual([]);
    });
}

Benefits of Component-Level Testing

Benefit Page-Level Testing Component-Level Testing
Speed Slower (full page render) Faster (isolated component)
Isolation Side effects from other components Pure component behavior
State coverage Hard to reach all states Easy to render each state
Debugging "Something changed on the page" "This specific component changed"
Maintenance Tests break when layout changes Tests stable if component API unchanged

Component-level visual testing in Storybook catches issues earlier than page-level screenshots, at a fraction of the cost. It is the visual testing equivalent of unit tests vs integration tests -- do both, but start with components.