Storybook交互测试函数Skill storybook-play-functions

Storybook交互测试函数是一种前端开发测试技能,用于通过编写play函数在Storybook故事中自动化测试React等组件的用户交互、状态变化和边界情况。它支持模拟真实用户行为、集成Testing Library进行语义查询、处理异步操作和验证错误状态。关键词包括Storybook、交互测试、前端测试、自动化测试、组件测试、Testing Library、play函数。

测试 0 次安装 0 次浏览 更新于 3/25/2026

名称: 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: 设置测试运行器以进行自动化测试