[React] Storybook Test Runner로 VRT + 접근성 테스트 구축하기

2026-03-06 hit count image

Storybook Test Runner와 jest-image-snapshot을 활용하여 React 컴포넌트의 VRT(Visual Regression Testing)와 접근성 테스트를 구축하는 방법을 공유합니다.

react

개요

공통 컴포넌트의 테스트 방침을 검토하면서, Storybook의 Test Runner를 활용한 VRT(Visual Regression Testing)와 접근성 테스트 환경을 구축해 보았습니다.

이 글은 공통 컴포넌트의 테스트 방침을 결정하는 과정에서 Storybook 기반 테스트를 검증한 내용을 정리한 것입니다. 전체 테스트 방침과 최종 도구 선정에 대해서는 [React] 모노레포 환경에서 공통 컴포넌트 테스트 방침을 참고해 주세요.

Storybook Test Runner는 Storybook에 등록된 Story를 실제 브라우저(Playwright)에서 렌더링하고, postVisit 훅에서 스크린샷 캡처 및 접근성 검사를 수행할 수 있습니다.

전체 구조

Storybook Test Runner 기반 VRT의 동작 흐름은 다음과 같습니다.

  1. Storybook을 빌드하고 정적 서버로 실행
  2. Test Runner가 Playwright 브라우저를 실행하여 각 Story를 방문
  3. preVisit 훅에서 axe-core를 페이지에 주입
  4. Story가 렌더링되면 play 함수가 실행 (이벤트 테스트)
  5. postVisit 훅에서 스크린샷을 캡처하고 jest-image-snapshot으로 비교
  6. 접근성 테스트 Story(a-11-y가 포함된 ID)는 axe-playwright로 검사
Storybook Build → Static Server (port 6006)
  → Test Runner (Playwright)
    → preVisit: axe-core 주입
    → Story 렌더링 + play 함수 실행
    → postVisit: 스크린샷 캡처 + 스냅샷 비교 + 접근성 검사

환경 설정

Storybook Test Runner 기반 VRT + 접근성 테스트를 하기 위해서는 먼저 테스트 환경을 설정해야 합니다.

패키지 설치

Storybook Test Runner와 VRT/접근성 테스트를 위해서 아래 명령어를 실행하여 필요한 패키지를 설치합니다.

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

설치한 패키지의 역할은 다음과 같습니다.

패키지역할
@storybook/test-runnerStorybook의 Story를 Playwright 브라우저에서 테스트 실행
playwright헤드리스 브라우저 자동화 (Test Runner의 런타임)
jest-image-snapshot스크린샷 이미지를 기존 스냅샷과 비교
axe-playwrightPlaywright 페이지에서 접근성(a11y) 검사 수행

Test Runner 설정

이번 설정의 핵심 파일입니다. .storybook/test-runner.tspostVisit 훅을 설정하여, Story 렌더링 후 스크린샷 캡처와 접근성 검사를 수행합니다.

// .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() {
    // jest-image-snapshot의 커스텀 matcher 등록
    expect.extend({ toMatchImageSnapshot });
  },

  async preVisit(page) {
    // 각 Story 방문 전에 axe-core를 페이지에 주입
    await injectAxe(page);
  },

  async postVisit(page, context) {
    // test.stories.tsx 파일의 Story만 테스트 대상으로 함
    const storyIdParts = context.id.split('--');
    const componentPath = storyIdParts[0];

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

    // 접근성 테스트: Story ID에 '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);
      }
    }

    // CSS, 폰트, 애니메이션 안정화 대기
    await page.waitForTimeout(1000);

    // 리스트 아이템 등 비동기 렌더링 대기
    await page
      .waitForSelector('#storybook-root ul li', { timeout: 5000 })
      .catch(() => {
        // 리스트 아이템이 없는 Story (빈 상태, 에러 상태 등)는 무시
      });

    // 스크린샷 캡처
    const image = await page.screenshot({
      fullPage: false,
      animations: 'disabled',
    });

    // 기존 스냅샷과 비교
    expect(image).toMatchImageSnapshot({
      customSnapshotsDir: snapshotsDir,
      customSnapshotIdentifier: context.id,
      failureThreshold: 0.001,
      failureThresholdType: 'percent',
      storeReceivedOnFailure: true,
      updatePassedSnapshot: process.env.UPDATE_SNAPSHOTS === 'true',
    });
  },
};

export default config;

여기서 핵심 포인트는 다음과 같습니다.

  • setup(): jest-image-snapshot의 커스텀 matcher를 등록합니다
  • preVisit(): 각 Story 방문 전에 axe-core를 페이지에 주입합니다. 이를 통해 postVisit에서 접근성 검사를 수행할 수 있습니다
  • postVisit(): Story 렌더링 후 스크린샷 캡처, 스냅샷 비교, 접근성 검사를 수행합니다
  • 테스트 대상 필터링: componentPath.endsWith('-test')*.test.stories.tsx 파일의 Story만 테스트합니다. 이를 통해 일반 Story에는 영향을 주지 않습니다
  • 접근성 테스트 트리거: Story ID에 a-11-y가 포함되어 있으면 접근성 검사를 수행합니다
  • 스냅샷 갱신: UPDATE_SNAPSHOTS=true 환경 변수로 스냅샷을 갱신할 수 있습니다

.gitignore 설정

VRT 테스트의 기준(baseline)이 될 스냅샷은 커밋해야합니다. 하지만, 테스트 실패 시 생성되는 diff 이미지와 received 이미지는 Git에서 제외할 필요가 있습니다.

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

테스트 Story 작성

이제 Storybook Test Runner 기반 VRT + 접근성 테스트를 위한 Story를 작성해 보겠습니다.

파일 컨벤션

테스트 전용 Story는 *.test.stories.tsx 파일명 컨벤션을 사용합니다.

컴포넌트명/
├── index.tsx                  # 컴포넌트 구현
├── index.stories.tsx          # 일반 Story (디자인/구현 확인용)
└── sample.test.stories.tsx    # 테스트 전용 Story (VRT + 이벤트 + 접근성)

다음과 같은 이유로 테스트 Story를 일반 Story와 분리하였습니다.

  • 일반 Story는 디자인 시스템 문서화와 개발 중 확인 용도로 사용합니다
  • 테스트 전용 Story는 렌더링/이벤트/접근성 테스트에 집중합니다
  • Test Runner 설정에서 -test로 끝나는 Story만 필터링하므로, 일반 Story의 스크린샷은 생성되지 않습니다

렌더링 테스트 (Props 변경에 따른 View 확인)

각 Props 조합에 대해 별도의 Story를 작성하여 VRT를 통해 View를 검증합니다.

// 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>;

// 빈 상태 렌더링
export const RenderingEmptyState: Story = {
  name: 'Rendering: 빈 상태',
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList fetchChildNodesFunction={createMockFetchFunction()} />
    </div>
  ),
};

// 트리 노드 표시
export const RenderingTopTreeNodes: Story = {
  name: 'Rendering: 트리 노드 표시',
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList
        topTreeNodes={sampleTopTreeNodes}
        fetchChildNodesFunction={createMockFetchFunction()}
      />
    </div>
  ),
};

// 카운트 라벨 표시
export const RenderingCountLabel: Story = {
  name: 'Rendering: 카운트 라벨 표시',
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList
        topTreeNodes={sampleTopTreeNodes}
        countLabel="${count}건"
        fetchChildNodesFunction={createMockFetchFunction()}
      />
    </div>
  ),
};

// 삭제 버튼 표시 (onRemove Props 제공 시)
export const RenderingRemoveButtons: Story = {
  name: 'Rendering: onRemove 제공 시 삭제 버튼 표시',
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList
        topTreeNodes={sampleTopTreeNodes}
        fetchChildNodesFunction={createMockFetchFunction()}
        onRemove={fn()}
      />
    </div>
  ),
};

Story마다 하나의 Props 조합을 테스트하므로, 어떤 Props가 어떤 View를 만드는지 명확하게 확인할 수 있습니다.

이벤트 테스트

Storybook의 play 함수를 사용하여 사용자 인터랙션을 시뮬레이션할 수 있습니다. 또, play 함수 실행 후에 postVisit 훅에서 스크린샷으로 캡처됩니다.

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

// 자식 노드 표시 테스트
export const EventDisplayChildNodes: Story = {
  name: 'Event: 페치 후 자식 노드 표시',
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList
        topTreeNodes={sampleTopTreeNodes}
        fetchChildNodesFunction={createMockFetchFunction()}
      />
    </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));

    // 자식 노드 표시 확인
    expect(canvas.getByText('Child 1')).toBeInTheDocument();
    expect(canvas.getByText('Child 2')).toBeInTheDocument();
  },
};

// 페치 에러 표시 테스트
export const EventFetchError: Story = {
  name: 'Event: 페치 에러 표시',
  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));

    // 에러 메시지 표시 확인
    expect(canvas.getByText('에러가 발생했습니다')).toBeInTheDocument();
  },
};

// Props 변경 시 노드 업데이트 테스트
export const RenderingPropsChange: Story = {
  name: 'Rendering: Props 변경 시 노드 업데이트',
  render: () => {
    const TestComponent = () => {
      const [nodes, setNodes] = useState(sampleTopTreeNodes);
      return (
        <div style={{ height: '25rem' }}>
          <button
            onClick={() =>
              setNodes([
                { value: 'new', label: 'New Node', hasChildren: false },
              ])
            }
          >
            노드 변경
          </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('노드 변경'));

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

접근성 테스트

Storybook에서도 axe-playwright를 활용하여 접근성 검사를 수행할 수 있습니다. 다음과 같이 Story 이름에 A11y를 포함시키면, Story ID에 a-11-y가 포함되어 Test Runner가 자동으로 접근성 검사를 수행합니다.

export const A11yTest: Story = {
  name: 'Accessibility: 접근성 테스트',
  tags: ['a11y-test'],
  render: () => (
    <div style={{ height: '25rem' }}>
      <ExpandableTreeList
        topTreeNodes={sampleTopTreeNodes}
        sectionLabel="접근성 테스트"
        countLabel="${count}건"
        fetchChildNodesFunction={createMockFetchFunction()}
        onRemove={fn()}
      />
    </div>
  ),
  parameters: {
    chromatic: { disableSnapshot: true },
    a11y: {
      config: {
        rules: [
          {
            id: 'button-name',
            enabled: false, // TODO: 확장/삭제 버튼에 aria-label 추가
          },
        ],
      },
    },
  },
};

접근성 검사는 axe-playwrightcheckA11y를 통해 수행되며, WCAG 위반 사항이 있으면 테스트가 실패합니다.

실행 스크립트

이렇게 설정한 Storybook Test Runner 기반 VRT 테스트는 다음과 같은 스크립트로 실행할 수 있습니다.

// 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\""
  }
}

스크립트의 동작 순서는 다음과 같습니다.

  1. yarn build-storybook: Storybook을 정적 파일로 빌드
  2. http-server storybook-static --port 6006: 빌드된 Storybook을 로컬 서버로 실행
  3. wait-on tcp:127.0.0.1:6006: 서버가 준비될 때까지 대기
  4. test-storybook --url http://127.0.0.1:6006: Test Runner 실행

각각의 스크립트는 다음과 같은 용도로 사용됩니다.

  • test:visual:ci: CI 환경에서 VRT + 접근성 테스트 실행
  • test:visual:update: 기준 스냅샷 갱신 (컴포넌트를 의도적으로 변경한 경우)
  • test:a11y: 접근성 테스트 실행

디렉토리 구조

이번 글에서 생성하거나 수정하는 파일들의 디렉토리 구조는 다음과 같습니다.

프로젝트 루트/
├── .storybook/
│   ├── main.ts                    # Storybook 설정 (기존 파일)
│   └── test-runner.ts             # Test Runner 설정 (신규 생성)
├── __image_snapshots__/           # 기준 스냅샷 이미지 (자동 생성, Git 커밋 대상)
│   ├── component-name-test--story-name.png
│   └── ...
├── __diff_output__/               # 테스트 실패 시 diff 이미지 (.gitignore 대상)
├── __received_output__/           # 테스트 실패 시 received 이미지 (.gitignore 대상)
├── .gitignore                     # diff/received 디렉토리 제외 추가 (수정)
├── package.json                   # 실행 스크립트 추가 (수정)
└── src/
    └── components/
        └── List/
            └── ExpandableTreeList/
                ├── index.tsx
                ├── index.stories.tsx          # 일반 Story (기존 파일)
                └── sample.test.stories.tsx    # 테스트 전용 Story (신규 생성)

정리

이 글에서는 Storybook Test Runner를 활용한 VRT + 접근성 테스트 환경 구축 방법을 소개했습니다.

항목내용
렌더링/VRTStorybook Test Runner + jest-image-snapshot
이벤트 테스트Storybook play 함수로 인터랙션 시뮬레이션
접근성 테스트axe-playwright로 WCAG 위반 검사
테스트 분리*.test.stories.tsx 파일 컨벤션으로 테스트 Story 분리
스냅샷 관리__image_snapshots__/에 기준 이미지 저장, UPDATE_SNAPSHOTS로 갱신

Storybook을 활용한 방식은 별도의 렌더링 유틸리티 없이 Story를 그대로 테스트에 활용할 수 있다는 장점이 있습니다. 다만, 프로덕트의 단위 테스트(Jest/Vitest)와 작성 방식이 달라 팀 내에서 두 가지 테스트 패턴을 관리해야 하고, Story 파일이 방대해질 수 있다는 점은 고려가 필요합니다.

Jest + Puppeteer를 활용한 VRT 구축 방법이 궁금하신 분은 [React] Jest + Puppeteer로 VRT 구축하기를 참고해 주세요.

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.



SHARE
Twitter Facebook RSS