名称: storybook-play-functions 用户可调用: false 描述: 在Storybook故事中添加交互测试时使用。允许直接在故事中进行组件行为、用户交互和状态变化的自动化测试。 允许的工具:
- 读取
- 写入
- 编辑
- Bash
- Grep
- Glob
Storybook - Play Functions
使用play函数在故事中编写自动化交互测试,以验证组件行为、模拟用户操作和测试边界情况。
关键概念
Play Functions
Play函数在故事渲染后运行,允许您模拟用户交互:
import { within, userEvent, expect } from '@storybook/test';
import type { Meta, StoryObj } from '@storybook/react';
import { LoginForm } from './LoginForm';
const meta = {
component: LoginForm,
} satisfies Meta<typeof LoginForm>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FilledForm: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'password123');
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
await expect(canvas.getByText('Welcome!')).toBeInTheDocument();
},
};
Testing Library Integration
Storybook集成Testing Library进行查询和交互:
within(canvasElement)- 将查询范围限定到故事userEvent- 模拟真实用户交互expect- Jest兼容的断言waitFor- 等待异步变化
Test Execution
Play函数执行于:
- 在Storybook中查看故事时
- 在视觉回归测试期间
- 在测试运行器中进行自动化测试
- 在开发期间故事热重载时
最佳实践
1. 使用Testing Library查询
使用语义查询来查找元素:
export const SearchFlow: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 好 - 语义查询
const searchInput = canvas.getByRole('searchbox');
const submitButton = canvas.getByRole('button', { name: /search/i });
const results = canvas.getByRole('list', { name: /results/i });
await userEvent.type(searchInput, 'storybook');
await userEvent.click(submitButton);
await expect(results).toBeInTheDocument();
},
};
2. 模拟真实用户行为
使用userEvent进行真实交互:
import { userEvent } from '@storybook/test';
export const FormInteraction: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 以延迟方式自然输入
await userEvent.type(canvas.getByLabelText('Name'), 'John Doe', {
delay: 100,
});
// 在字段间切换
await userEvent.tab();
// 从下拉菜单中选择
await userEvent.selectOptions(
canvas.getByLabelText('Country'),
'United States'
);
// 点击提交
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
},
};
3. 测试异步行为
使用waitFor处理异步状态变化:
import { waitFor } from '@storybook/test';
export const AsyncData: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /load data/i }));
// 等待加载状态
await waitFor(() => {
expect(canvas.getByText('Loading...')).toBeInTheDocument();
});
// 等待数据出现
await waitFor(
() => {
expect(canvas.getByRole('list')).toBeInTheDocument();
expect(canvas.getAllByRole('listitem')).toHaveLength(5);
},
{ timeout: 3000 }
);
},
};
4. 测试错误状态
验证错误处理和验证:
export const ValidationErrors: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 提交空表单
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
// 验证错误消息
await expect(canvas.getByText('Email是必填项')).toBeInTheDocument();
await expect(canvas.getByText('Password是必填项')).toBeInTheDocument();
// 仅填写邮箱
await userEvent.type(canvas.getByLabelText('Email'), 'invalid-email');
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
// 验证邮箱验证
await expect(canvas.getByText('Email无效')).toBeInTheDocument();
},
};
5. 组合复杂场景
将复杂交互分解为步骤:
export const CheckoutFlow: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 步骤1: 添加商品到购物车
await userEvent.click(canvas.getByRole('button', { name: /add to cart/i }));
await expect(canvas.getByText('1件商品在购物车中')).toBeInTheDocument();
// 步骤2: 进入结账
await userEvent.click(canvas.getByRole('button', { name: /checkout/i }));
await expect(canvas.getByRole('heading', { name: /checkout/i })).toBeInTheDocument();
// 步骤3: 填写配送信息
await userEvent.type(canvas.getByLabelText('Address'), '123 Main St');
await userEvent.type(canvas.getByLabelText('City'), 'New York');
await userEvent.selectOptions(canvas.getByLabelText('State'), 'NY');
// 步骤4: 提交订单
await userEvent.click(canvas.getByRole('button', { name: /place order/i }));
await waitFor(() => {
expect(canvas.getByText('订单已确认!')).toBeInTheDocument();
});
},
};
常见模式
模态框交互
export const OpenModal: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 初始时模态框不可见
expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();
// 点击触发器
await userEvent.click(canvas.getByRole('button', { name: /open/i }));
// 模态框出现
const modal = canvas.getByRole('dialog');
await expect(modal).toBeInTheDocument();
// 关闭模态框
await userEvent.click(within(modal).getByRole('button', { name: /close/i }));
// 模态框消失
await waitFor(() => {
expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();
});
},
};
键盘导航
export const KeyboardNav: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const firstItem = canvas.getAllByRole('menuitem')[0];
firstItem.focus();
// 使用箭头键导航
await userEvent.keyboard('{ArrowDown}');
await expect(canvas.getAllByRole('menuitem')[1]).toHaveFocus();
await userEvent.keyboard('{ArrowDown}');
await expect(canvas.getAllByRole('menuitem')[2]).toHaveFocus();
// 使用Enter选择
await userEvent.keyboard('{Enter}');
await expect(canvas.getByText('Item 3已选择')).toBeInTheDocument();
// 使用Escape关闭
await userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(canvas.queryByRole('menu')).not.toBeInTheDocument();
});
},
};
多步骤表单
export const Wizard: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// 步骤1
await userEvent.type(canvas.getByLabelText('First Name'), 'John');
await userEvent.type(canvas.getByLabelText('Last Name'), 'Doe');
await userEvent.click(canvas.getByRole('button', { name: /next/i }));
// 步骤2
await expect(canvas.getByText('第2步,共3步')).toBeInTheDocument();
await userEvent.type(canvas.getByLabelText('Email'), 'john@example.com');
await userEvent.click(canvas.getByRole('button', { name: /next/i }));
// 步骤3
await expect(canvas.getByText('第3步,共3步')).toBeInTheDocument();
await userEvent.click(canvas.getByRole('checkbox', { name: /agree/i }));
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
// 成功
await waitFor(() => {
expect(canvas.getByText('注册完成!')).toBeInTheDocument();
});
},
};
拖放操作
export const DragDrop: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const draggable = canvas.getByRole('button', { name: /drag me/i });
const dropzone = canvas.getByRole('region', { name: /drop zone/i });
// 执行拖放
await userEvent.pointer([
{ keys: '[MouseLeft>]', target: draggable },
{ coords: { x: 100, y: 100 } },
{ target: dropzone },
{ keys: '[/MouseLeft]' },
]);
await expect(canvas.getByText('项目已放置!')).toBeInTheDocument();
},
};
文件上传
export const FileUpload: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
const input = canvas.getByLabelText('Upload file');
await userEvent.upload(input, file);
await expect(canvas.getByText('test.txt')).toBeInTheDocument();
await expect(canvas.getByText('1个文件已选择')).toBeInTheDocument();
},
};
高级模式
可重用的Play函数
// helpers.ts
export async function login(canvas: ReturnType<typeof within>) {
await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com');
await userEvent.type(canvas.getByLabelText('Password'), 'password123');
await userEvent.click(canvas.getByRole('button', { name: /login/i }));
}
// Story.stories.tsx
export const AfterLogin: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await login(canvas);
// 测试认证状态
await expect(canvas.getByText('欢迎, 用户!')).toBeInTheDocument();
},
};
步骤化测试
import { step } from '@storybook/test';
export const MultiStep: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await step('填写个人信息', async () => {
await userEvent.type(canvas.getByLabelText('Name'), 'John Doe');
await userEvent.type(canvas.getByLabelText('Email'), 'john@example.com');
});
await step('选择偏好', async () => {
await userEvent.click(canvas.getByLabelText('Subscribe to newsletter'));
await userEvent.selectOptions(canvas.getByLabelText('Theme'), 'dark');
});
await step('提交表单', async () => {
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
await expect(canvas.getByText('成功!')).toBeInTheDocument();
});
},
};
反模式
❌ 不要使用直接的DOM操作
// 坏
export const Bad: Story = {
play: async ({ canvasElement }) => {
const input = canvasElement.querySelector('input');
input.value = 'text';
input.dispatchEvent(new Event('input'));
},
};
// 好
export const Good: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByRole('textbox'), 'text');
},
};
❌ 不要忘记异步/等待
// 坏 - 缺少await
export const Bad: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
userEvent.click(canvas.getByRole('button')); // 不会工作!
expect(canvas.getByText('Clicked')).toBeInTheDocument();
},
};
// 好
export const Good: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(canvas.getByText('Clicked')).toBeInTheDocument();
},
};
❌ 不要使用脆弱的选择器
// 坏 - 脆弱的选择器
export const Bad: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText('Submit')); // 如果文本更改会中断
},
};
// 好 - 语义选择器
export const Good: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
},
};
相关技能
- storybook-story-writing: 创建故事以使用play函数进行测试
- storybook-args-controls: 使用args测试不同组件状态
- storybook-configuration: 设置测试运行器以进行自动化测试