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