Storybook 测试 #
测试概述 #
Storybook 提供了完整的测试解决方案,包括组件测试、交互测试、视觉测试和无障碍测试。
text
┌─────────────────────────────────────────────────────────────┐
│ Storybook 测试体系 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 组件测试(Component Testing) │
│ ├── 验证组件渲染 │
│ ├── 测试组件行为 │
│ └── 检查组件状态 │
│ │
│ 交互测试(Interaction Testing) │
│ ├── 用户操作模拟 │
│ ├── 事件处理验证 │
│ └── 状态变化检查 │
│ │
│ 视觉测试(Visual Testing) │
│ ├── 快照对比 │
│ ├── 视觉回归测试 │
│ └── 跨浏览器测试 │
│ │
│ 无障碍测试(Accessibility Testing) │
│ ├── A11y 规则检查 │
│ ├── 键盘导航测试 │
│ └── 屏幕阅读器兼容 │
│ │
└─────────────────────────────────────────────────────────────┘
组件测试 #
安装依赖 #
bash
npm install --save-dev @storybook/addon-interactions @storybook/test
配置 #
javascript
// .storybook/main.js
export default {
addons: ['@storybook/addon-interactions'],
};
基本测试 #
javascript
import { within, expect } from '@storybook/test';
import Button from './Button';
export default {
title: 'Components/Button',
component: Button,
};
export const Primary = {
args: {
variant: 'primary',
children: 'Click me',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
// 验证按钮存在
await expect(button).toBeInTheDocument();
// 验证按钮文本
await expect(button).toHaveTextContent('Click me');
},
};
交互测试 #
Play 函数 #
Play 函数用于定义交互测试:
javascript
import { within, userEvent, expect, fn } from '@storybook/test';
export const InteractiveButton = {
args: {
onClick: fn(), // 创建 mock 函数
children: 'Click me',
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
// 点击按钮
await userEvent.click(button);
// 验证点击被调用
await expect(args.onClick).toHaveBeenCalled();
},
};
用户交互 #
javascript
import { within, userEvent, expect } from '@storybook/test';
// 点击
export const ClickTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
},
};
// 双击
export const DoubleClickTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const element = canvas.getByTestId('double-click');
await userEvent.dblClick(element);
},
};
// 悬停
export const HoverTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const element = canvas.getByTestId('hover');
await userEvent.hover(element);
},
};
// 输入文本
export const TypeTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText('Username');
await userEvent.type(input, 'hello world');
},
};
// 清除文本
export const ClearTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText('Username');
await userEvent.clear(input);
},
};
// 选择选项
export const SelectTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const select = canvas.getByRole('combobox');
await userEvent.selectOptions(select, 'option1');
},
};
// 上传文件
export const UploadTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText('Upload');
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
await userEvent.upload(input, file);
},
};
// 键盘操作
export const KeyboardTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText('Search');
await userEvent.click(input);
await userEvent.keyboard('Hello');
await userEvent.keyboard('{Enter}');
},
};
// Tab 导航
export const TabTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.tab();
const firstButton = canvas.getByRole('button', { name: 'First' });
await expect(firstButton).toHaveFocus();
await userEvent.tab();
const secondButton = canvas.getByRole('button', { name: 'Second' });
await expect(secondButton).toHaveFocus();
},
};
表单测试 #
javascript
import { within, userEvent, expect, fn } from '@storybook/test';
export const FormSubmission = {
args: {
onSubmit: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
// 填写表单
await userEvent.type(canvas.getByLabelText('用户名'), 'testuser');
await userEvent.type(canvas.getByLabelText('邮箱'), 'test@example.com');
await userEvent.type(canvas.getByLabelText('密码'), 'password123');
// 提交表单
await userEvent.click(canvas.getByRole('button', { name: '注册' }));
// 验证提交
await expect(args.onSubmit).toHaveBeenCalledWith({
username: 'testuser',
email: 'test@example.com',
password: 'password123',
});
},
};
异步测试 #
javascript
import { within, userEvent, expect, waitFor } from '@storybook/test';
export const AsyncLoad = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 点击加载按钮
await userEvent.click(canvas.getByRole('button', { name: '加载数据' }));
// 等待数据加载
await waitFor(async () => {
await expect(canvas.getByText('数据已加载')).toBeInTheDocument();
});
},
};
步骤化测试 #
javascript
import { within, userEvent, expect } from '@storybook/test';
export const StepByStep = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('显示初始状态', async () => {
await expect(canvas.getByText('未开始')).toBeInTheDocument();
});
await step('点击开始按钮', async () => {
await userEvent.click(canvas.getByRole('button', { name: '开始' }));
});
await step('验证状态变化', async () => {
await expect(canvas.getByText('进行中')).toBeInTheDocument();
});
await step('完成任务', async () => {
await userEvent.click(canvas.getByRole('button', { name: '完成' }));
await expect(canvas.getByText('已完成')).toBeInTheDocument();
});
},
};
断言方法 #
DOM 断言 #
javascript
import { within, expect } from '@storybook/test';
export const DomAssertions = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const element = canvas.getByTestId('test-element');
// 存在性
await expect(element).toBeInTheDocument();
await expect(element).not.toBeInTheDocument();
// 可见性
await expect(element).toBeVisible();
await expect(element).toBeHidden();
// 禁用状态
await expect(element).toBeDisabled();
await expect(element).toBeEnabled();
// 选中状态
await expect(element).toBeChecked();
await expect(element).not.toBeChecked();
// 焦点状态
await expect(element).toHaveFocus();
await expect(element).not.toHaveFocus();
// 文本内容
await expect(element).toHaveTextContent('Hello');
await expect(element).toHaveTextContent(/hello/i);
// 属性
await expect(element).toHaveAttribute('href', '/path');
await expect(element).toHaveClass('active');
await expect(element).toHaveStyle({ color: 'red' });
// 值
await expect(element).toHaveValue('test');
await expect(element).toHaveDisplayValue('Display Value');
},
};
Mock 函数断言 #
javascript
import { within, userEvent, expect, fn } from '@storybook/test';
export const MockAssertions = {
args: {
onClick: fn(),
onChange: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
// 调用断言
await expect(args.onClick).toHaveBeenCalled();
await expect(args.onClick).toHaveBeenCalledTimes(1);
await expect(args.onClick).toHaveBeenCalledWith({ value: 'test' });
// 调用顺序
await expect(args.onClick).toHaveBeenCalledBefore(args.onChange);
},
};
视觉测试 #
快照测试 #
bash
npm install --save-dev @storybook/addon-storyshots
javascript
// storybook.test.js
import initStoryshots from '@storybook/addon-storyshots';
initStoryshots({
suite: 'Storybook Snapshots',
});
运行测试 #
bash
# 运行所有测试
npm run test-storybook
# 更新快照
npm run test-storybook -- -u
视觉回归测试 #
使用 Chromatic 进行视觉回归测试:
bash
# 安装
npm install --save-dev chromatic
# 运行
npx chromatic --project-token <your-token>
配置 CI/CD:
yaml
# .github/workflows/chromatic.yml
name: Chromatic
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install dependencies
run: npm ci
- name: Publish to Chromatic
uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
无障碍测试 #
配置 A11y 插件 #
bash
npm install --save-dev @storybook/addon-a11y
javascript
// .storybook/main.js
export default {
addons: ['@storybook/addon-a11y'],
};
A11y 测试 #
javascript
import { within, expect } from '@storybook/test';
export const A11yTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 检查图片有 alt 属性
const images = canvas.getAllByRole('img');
for (const img of images) {
await expect(img).toHaveAttribute('alt');
}
// 检查按钮有可访问名称
const buttons = canvas.getAllByRole('button');
for (const button of buttons) {
await expect(button).toHaveAccessibleName();
}
// 检查表单控件有标签
const inputs = canvas.getAllByRole('textbox');
for (const input of inputs) {
await expect(input).toHaveAccessibleName();
}
},
};
A11y 规则配置 #
javascript
export default {
parameters: {
a11y: {
element: '#root',
config: {
rules: [
// 禁用特定规则
{
id: 'color-contrast',
enabled: false,
},
],
},
options: {},
manual: false,
},
},
};
测试覆盖率 #
配置覆盖率 #
bash
npm install --save-dev @storybook/addon-coverage
javascript
// .storybook/main.js
export default {
addons: ['@storybook/addon-coverage'],
};
查看覆盖率 #
bash
# 运行测试并生成覆盖率报告
npm run test-storybook -- --coverage
# 输出覆盖率报告
# ----------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------|---------|----------|---------|---------|
# All files | 85.5 | 78.2 | 90.1 | 85.5 |
# Button.js | 90.0 | 85.0 | 95.0 | 90.0 |
# Input.js | 80.0 | 70.0 | 85.0 | 80.0 |
# ----------|---------|----------|---------|---------|
测试工具函数 #
查询元素 #
javascript
import { within, expect } from '@storybook/test';
export const QueryMethods = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// getBy - 获取单个元素(找不到则报错)
const button = canvas.getByRole('button');
const input = canvas.getByLabelText('Username');
const text = canvas.getByText('Hello');
const testId = canvas.getByTestId('submit-btn');
// getAllBy - 获取多个元素
const buttons = canvas.getAllByRole('button');
const items = canvas.getAllByText(/item/i);
// queryBy - 获取单个元素(找不到返回 null)
const optional = canvas.queryByRole('dialog');
if (optional) {
// 元素存在
}
// queryAllBy - 获取多个元素
const allButtons = canvas.queryAllByRole('button');
// findBy - 异步获取(等待元素出现)
const asyncElement = await canvas.findByText('Loaded');
// findAllBy - 异步获取多个
const asyncElements = await canvas.findAllByRole('listitem');
},
};
等待和超时 #
javascript
import { within, userEvent, expect, waitFor } from '@storybook/test';
export const WaitForExample = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 等待条件满足
await waitFor(async () => {
await expect(canvas.getByText('Loaded')).toBeInTheDocument();
}, {
timeout: 5000,
interval: 100,
});
// 等待元素消失
await waitFor(async () => {
await expect(canvas.queryByText('Loading')).not.toBeInTheDocument();
});
},
};
测试最佳实践 #
1. 测试用户行为 #
javascript
// ✅ 好的做法 - 测试用户行为
export const GoodTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button', { name: '提交' });
await userEvent.click(button);
await expect(canvas.getByText('提交成功')).toBeInTheDocument();
},
};
// ❌ 不好的做法 - 测试实现细节
export const BadTest = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByTestId('submit-button');
await userEvent.click(button);
await expect(button).toHaveClass('submitted');
},
};
2. 使用可访问性查询 #
javascript
// ✅ 优先使用可访问性查询
const button = canvas.getByRole('button');
const input = canvas.getByLabelText('Username');
const heading = canvas.getByRole('heading', { name: '标题' });
// ⚠️ 次选文本查询
const text = canvas.getByText('Hello');
// ❌ 避免使用 testId
const element = canvas.getByTestId('submit');
3. 组织测试步骤 #
javascript
export const OrganizedTest = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('初始化状态检查', async () => {
await expect(canvas.getByText('初始状态')).toBeInTheDocument();
});
await step('用户操作', async () => {
await userEvent.click(canvas.getByRole('button'));
});
await step('结果验证', async () => {
await expect(canvas.getByText('操作成功')).toBeInTheDocument();
});
},
};
4. Mock 外部依赖 #
javascript
import { fn } from '@storybook/test';
export const WithMock = {
args: {
// Mock API 调用
fetchUser: fn().mockResolvedValue({ id: 1, name: 'Test User' }),
// Mock 事件处理
onClick: fn(),
// Mock 外部函数
validate: fn().mockReturnValue(true),
},
play: async ({ args, canvasElement }) => {
// 测试使用 mock
},
};
CI/CD 集成 #
GitHub Actions #
yaml
# .github/workflows/test.yml
name: Storybook Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Run Storybook tests
run: |
npx concurrently -k -s first "npx http-server storybook-static --port 6006" "npx wait-on http://localhost:6006 && npm run test-storybook"
package.json 脚本 #
json
{
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test-storybook": "test-storybook",
"test-storybook:ci": "concurrently -k -s first \"npm run storybook\" \"wait-on http://localhost:6006 && npm run test-storybook\""
}
}
下一步 #
现在你已经掌握了 Storybook 测试的方法,接下来学习 高级主题 了解更多进阶技巧!
最后更新:2026-03-29