storybook-play-functions

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Storybook - Play Functions

Storybook - Play 函数

Write automated interaction tests within stories using play functions to verify component behavior, simulate user actions, and test edge cases.
使用play函数在故事中编写自动化交互测试,以验证组件行为、模拟用户操作并测试边缘情况。

Key Concepts

核心概念

Play Functions

Play 函数

Play functions run after a story renders, allowing you to simulate user interactions:
typescript
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();
  },
};
Play函数会在故事渲染完成后运行,允许你模拟用户交互:
typescript
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

Testing Library 集成

Storybook integrates with Testing Library for queries and interactions:
  • within(canvasElement)
    - Scopes queries to the story
  • userEvent
    - Simulates realistic user interactions
  • expect
    - Jest-compatible assertions
  • waitFor
    - Waits for async changes
Storybook与Testing Library集成,用于查询和交互:
  • within(canvasElement)
    - 将查询范围限定在当前故事内
  • userEvent
    - 模拟真实的用户交互
  • expect
    - 兼容Jest的断言方法
  • waitFor
    - 等待异步状态变更

Test Execution

测试执行时机

Play functions execute:
  • When viewing a story in Storybook
  • During visual regression testing
  • In test runners for automated testing
  • On story hot-reload during development
Play函数会在以下场景执行:
  • 在Storybook中查看故事时
  • 视觉回归测试期间
  • 自动化测试的测试运行器中
  • 开发过程中故事热重载时

Best Practices

最佳实践

1. Use Testing Library Queries

1. 使用Testing Library查询方法

Use semantic queries to find elements:
typescript
export const SearchFlow: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // Good - Semantic queries
    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();
  },
};
使用语义化查询来定位元素:
typescript
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. Simulate Realistic User Behavior

2. 模拟真实用户行为

Use
userEvent
for realistic interactions:
typescript
import { userEvent } from '@storybook/test';

export const FormInteraction: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // Type naturally with delay
    await userEvent.type(canvas.getByLabelText('Name'), 'John Doe', {
      delay: 100,
    });

    // Tab between fields
    await userEvent.tab();

    // Select from dropdown
    await userEvent.selectOptions(
      canvas.getByLabelText('Country'),
      'United States'
    );

    // Click submit
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
  },
};
使用
userEvent
模拟真实的交互行为:
typescript
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. Test Async Behavior

3. 测试异步行为

Use
waitFor
for async state changes:
typescript
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 }));

    // Wait for loading state
    await waitFor(() => {
      expect(canvas.getByText('Loading...')).toBeInTheDocument();
    });

    // Wait for data to appear
    await waitFor(
      () => {
        expect(canvas.getByRole('list')).toBeInTheDocument();
        expect(canvas.getAllByRole('listitem')).toHaveLength(5);
      },
      { timeout: 3000 }
    );
  },
};
使用
waitFor
处理异步状态变更:
typescript
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. Test Error States

4. 测试错误状态

Validate error handling and validation:
typescript
export const ValidationErrors: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // Submit empty form
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));

    // Verify error messages
    await expect(canvas.getByText('Email is required')).toBeInTheDocument();
    await expect(canvas.getByText('Password is required')).toBeInTheDocument();

    // Fill only email
    await userEvent.type(canvas.getByLabelText('Email'), 'invalid-email');
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));

    // Verify email validation
    await expect(canvas.getByText('Email is invalid')).toBeInTheDocument();
  },
};
验证错误处理和表单校验逻辑:
typescript
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 is required')).toBeInTheDocument();
    await expect(canvas.getByText('Password is required')).toBeInTheDocument();

    // 仅填写邮箱(格式错误)
    await userEvent.type(canvas.getByLabelText('Email'), 'invalid-email');
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));

    // 验证邮箱格式错误提示
    await expect(canvas.getByText('Email is invalid')).toBeInTheDocument();
  },
};

5. Compose Complex Scenarios

5. 组合复杂测试场景

Break complex interactions into steps:
typescript
export const CheckoutFlow: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // Step 1: Add items to cart
    await userEvent.click(canvas.getByRole('button', { name: /add to cart/i }));
    await expect(canvas.getByText('1 item in cart')).toBeInTheDocument();

    // Step 2: Proceed to checkout
    await userEvent.click(canvas.getByRole('button', { name: /checkout/i }));
    await expect(canvas.getByRole('heading', { name: /checkout/i })).toBeInTheDocument();

    // Step 3: Fill shipping info
    await userEvent.type(canvas.getByLabelText('Address'), '123 Main St');
    await userEvent.type(canvas.getByLabelText('City'), 'New York');
    await userEvent.selectOptions(canvas.getByLabelText('State'), 'NY');

    // Step 4: Submit order
    await userEvent.click(canvas.getByRole('button', { name: /place order/i }));
    await waitFor(() => {
      expect(canvas.getByText('Order confirmed!')).toBeInTheDocument();
    });
  },
};
将复杂交互拆分为多个步骤:
typescript
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 item in cart')).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('Order confirmed!')).toBeInTheDocument();
    });
  },
};

Common Patterns

常见测试模式

Modal Interactions

模态框交互

typescript
export const OpenModal: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // Modal not visible initially
    expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();

    // Click trigger
    await userEvent.click(canvas.getByRole('button', { name: /open/i }));

    // Modal appears
    const modal = canvas.getByRole('dialog');
    await expect(modal).toBeInTheDocument();

    // Close modal
    await userEvent.click(within(modal).getByRole('button', { name: /close/i }));

    // Modal disappears
    await waitFor(() => {
      expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();
    });
  },
};
typescript
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();
    });
  },
};

Keyboard Navigation

键盘导航

typescript
export const KeyboardNav: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    const firstItem = canvas.getAllByRole('menuitem')[0];
    firstItem.focus();

    // Navigate with arrow keys
    await userEvent.keyboard('{ArrowDown}');
    await expect(canvas.getAllByRole('menuitem')[1]).toHaveFocus();

    await userEvent.keyboard('{ArrowDown}');
    await expect(canvas.getAllByRole('menuitem')[2]).toHaveFocus();

    // Select with Enter
    await userEvent.keyboard('{Enter}');
    await expect(canvas.getByText('Item 3 selected')).toBeInTheDocument();

    // Close with Escape
    await userEvent.keyboard('{Escape}');
    await waitFor(() => {
      expect(canvas.queryByRole('menu')).not.toBeInTheDocument();
    });
  },
};
typescript
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();

    // 使用回车键选择
    await userEvent.keyboard('{Enter}');
    await expect(canvas.getByText('Item 3 selected')).toBeInTheDocument();

    // 使用ESC键关闭
    await userEvent.keyboard('{Escape}');
    await waitFor(() => {
      expect(canvas.queryByRole('menu')).not.toBeInTheDocument();
    });
  },
};

Multi-Step Forms

多步骤表单

typescript
export const Wizard: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // Step 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 }));

    // Step 2
    await expect(canvas.getByText('Step 2 of 3')).toBeInTheDocument();
    await userEvent.type(canvas.getByLabelText('Email'), 'john@example.com');
    await userEvent.click(canvas.getByRole('button', { name: /next/i }));

    // Step 3
    await expect(canvas.getByText('Step 3 of 3')).toBeInTheDocument();
    await userEvent.click(canvas.getByRole('checkbox', { name: /agree/i }));
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));

    // Success
    await waitFor(() => {
      expect(canvas.getByText('Registration complete!')).toBeInTheDocument();
    });
  },
};
typescript
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('Step 2 of 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('Step 3 of 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('Registration complete!')).toBeInTheDocument();
    });
  },
};

Drag and Drop

拖拽交互

typescript
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 });

    // Perform drag and drop
    await userEvent.pointer([
      { keys: '[MouseLeft>]', target: draggable },
      { coords: { x: 100, y: 100 } },
      { target: dropzone },
      { keys: '[/MouseLeft]' },
    ]);

    await expect(canvas.getByText('Item dropped!')).toBeInTheDocument();
  },
};
typescript
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('Item dropped!')).toBeInTheDocument();
  },
};

File Upload

文件上传

typescript
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 file selected')).toBeInTheDocument();
  },
};
typescript
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 file selected')).toBeInTheDocument();
  },
};

Advanced Patterns

高级测试模式

Reusable Play Functions

可复用的Play函数

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

    // Test authenticated state
    await expect(canvas.getByText('Welcome, User!')).toBeInTheDocument();
  },
};
typescript
// 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('Welcome, User!')).toBeInTheDocument();
  },
};

Step-Through Testing

分步测试

typescript
import { step } from '@storybook/test';

export const MultiStep: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await step('Fill in personal info', async () => {
      await userEvent.type(canvas.getByLabelText('Name'), 'John Doe');
      await userEvent.type(canvas.getByLabelText('Email'), 'john@example.com');
    });

    await step('Select preferences', async () => {
      await userEvent.click(canvas.getByLabelText('Subscribe to newsletter'));
      await userEvent.selectOptions(canvas.getByLabelText('Theme'), 'dark');
    });

    await step('Submit form', async () => {
      await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
      await expect(canvas.getByText('Success!')).toBeInTheDocument();
    });
  },
};
typescript
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('Success!')).toBeInTheDocument();
    });
  },
};

Anti-Patterns

反模式

❌ Don't Use Direct DOM Manipulation

❌ 不要直接操作DOM

typescript
// Bad
export const Bad: Story = {
  play: async ({ canvasElement }) => {
    const input = canvasElement.querySelector('input');
    input.value = 'text';
    input.dispatchEvent(new Event('input'));
  },
};
typescript
// Good
export const Good: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.type(canvas.getByRole('textbox'), 'text');
  },
};
typescript
// 错误示例
export const Bad: Story = {
  play: async ({ canvasElement }) => {
    const input = canvasElement.querySelector('input');
    input.value = 'text';
    input.dispatchEvent(new Event('input'));
  },
};
typescript
// 正确示例
export const Good: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.type(canvas.getByRole('textbox'), 'text');
  },
};

❌ Don't Forget Async/Await

❌ 不要忘记使用Async/Await

typescript
// Bad - Missing await
export const Bad: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    userEvent.click(canvas.getByRole('button'));  // Won't work!
    expect(canvas.getByText('Clicked')).toBeInTheDocument();
  },
};
typescript
// Good
export const Good: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button'));
    await expect(canvas.getByText('Clicked')).toBeInTheDocument();
  },
};
typescript
// 错误示例 - 缺少await
export const Bad: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    userEvent.click(canvas.getByRole('button'));  // 无法正常工作!
    expect(canvas.getByText('Clicked')).toBeInTheDocument();
  },
};
typescript
// 正确示例
export const Good: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button'));
    await expect(canvas.getByText('Clicked')).toBeInTheDocument();
  },
};

❌ Don't Use Brittle Selectors

❌ 不要使用脆弱的选择器

typescript
// Bad - Fragile selectors
export const Bad: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByText('Submit'));  // Breaks if text changes
  },
};
typescript
// Good - Semantic selectors
export const Good: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
  },
};
typescript
// 错误示例 - 脆弱的选择器
export const Bad: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByText('Submit'));  // 文本变更后会失效
  },
};
typescript
// 正确示例 - 语义化选择器
export const Good: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
  },
};

Related Skills

相关技能

  • storybook-story-writing: Creating stories to test with play functions
  • storybook-args-controls: Using args to test different component states
  • storybook-configuration: Setting up test runner for automated testing
  • storybook-story-writing: 创建用于play函数测试的故事
  • storybook-args-controls: 使用args测试不同的组件状态
  • storybook-configuration: 配置测试运行器以实现自动化测试