목차
개요
공통 컴포넌트의 테스트 방침을 검토하면서, Storybook의 Test Runner를 활용한 VRT(Visual Regression Testing)와 접근성 테스트 환경을 구축해 보았습니다.
이 글은 공통 컴포넌트의 테스트 방침을 결정하는 과정에서 Storybook 기반 테스트를 검증한 내용을 정리한 것입니다. 전체 테스트 방침과 최종 도구 선정에 대해서는 [React] 모노레포 환경에서 공통 컴포넌트 테스트 방침을 참고해 주세요.
Storybook Test Runner는 Storybook에 등록된 Story를 실제 브라우저(Playwright)에서 렌더링하고, postVisit 훅에서 스크린샷 캡처 및 접근성 검사를 수행할 수 있습니다.
전체 구조
Storybook Test Runner 기반 VRT의 동작 흐름은 다음과 같습니다.
- Storybook을 빌드하고 정적 서버로 실행
- Test Runner가 Playwright 브라우저를 실행하여 각 Story를 방문
preVisit훅에서 axe-core를 페이지에 주입- Story가 렌더링되면
play함수가 실행 (이벤트 테스트) postVisit훅에서 스크린샷을 캡처하고jest-image-snapshot으로 비교- 접근성 테스트 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-runner | Storybook의 Story를 Playwright 브라우저에서 테스트 실행 |
playwright | 헤드리스 브라우저 자동화 (Test Runner의 런타임) |
jest-image-snapshot | 스크린샷 이미지를 기존 스냅샷과 비교 |
axe-playwright | Playwright 페이지에서 접근성(a11y) 검사 수행 |
Test Runner 설정
이번 설정의 핵심 파일입니다. .storybook/test-runner.ts에 postVisit 훅을 설정하여, 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-playwright의 checkA11y를 통해 수행되며, 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\""
}
}
스크립트의 동작 순서는 다음과 같습니다.
yarn build-storybook: Storybook을 정적 파일로 빌드http-server storybook-static --port 6006: 빌드된 Storybook을 로컬 서버로 실행wait-on tcp:127.0.0.1:6006: 서버가 준비될 때까지 대기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 + 접근성 테스트 환경 구축 방법을 소개했습니다.
| 항목 | 내용 |
|---|---|
| 렌더링/VRT | Storybook 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 구축하기를 참고해 주세요.
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.