掌握 Storybook 的 step 函数:为交互测试分组、命名与精确调试
step 函数是 Storybook play 函数中用于对一组相关交互进行分组的关键 API,它为复杂流程提供自定义标签,并让 Interactions 面板以可折叠分组的形式呈现每步交互。本文将基于 Storybook 官方文档与仓库源码,讲解 step 函数的用法、CSF 3 与 CSF Next 两种写法、底层执行机制及调试技巧。
原文素材取自仓库文档 interaction-testing.mdx 引用的代码片段 storybook-interactions-step-function.md,并结合交互测试内核源码展开。
step 函数解决的问题:复杂交互流程的可读性与可调试性
在交互测试(Interaction tests)中,每个 story 的play函数负责模拟真实用户行为(点击、输入、提交表单),随后对结果进行断言。对于登录、下单这类流程较长的组件,play函数里往往会混入多组不同类型的操作,比如“先填表单、再提交、再等待响应”。此时仅从代码或 Interactions 面板的日志里,很难一眼分辨哪些操作属于同一个业务阶段。
step函数正是为此设计的:它接收一个描述性标签和一个回调(内部继续执行若干交互),从而把一组相关的userEvent操作打包成一个带名字的分组。在 Storybook UI 的 Interactions 面板中,这些分组会嵌套展示为可折叠的组,运行失败时也能直接定位到具体的步骤。
在 play 函数中使用 step:基础写法
step与args、canvas、userEvent一样,从 play 函数的 context 参数中解构获得。以下示例来自关联片段,它把“填写邮箱与密码”和“提交表单”拆成两个命名步骤:
// ...story file 其余内容 export const Submitted = { play: async ({ args, canvas, step, userEvent }) => { await step('Enter email and password', async () => { await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); await userEvent.type(canvas.getByTestId('password'), 'supersecret'); }); await step('Submit form', async () => { await userEvent.click(canvas.getByRole('button')); }); }, };// ...story file 其余内容 export const Submitted: Story = { play: async ({ args, canvas, step, userEvent }) => { await step('Enter email and password', async () => { await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); await userEvent.type(canvas.getByTestId('password'), 'supersecret'); }); await step('Submit form', async () => { await userEvent.click(canvas.getByRole('button')); }); }, };对上述示例逐点拆解:
step(label, play):第一个参数是StepLabel(即 string 类型标签),第二个参数是与 play 函数签名一致的异步回调,内部照常使用canvas上的 Testing Library 查询与userEvent模拟操作;- 查询优先按真实用户习惯:示例中用
getByTestId定位输入框、用getByRole('button')定位按钮。官方推荐优先使用ByRole、ByLabelText这类贴近无障碍语义的查询,data-testid应作为兜底手段(详见 querying the canvas); - 务必
await每一步:userEvent与step都应被await,这样 Interactions 面板才能完整记录并逐帧回放每一个交互。
运行后,Interactions 面板会把Enter email and password、Submit form呈现为带层级、可折叠的组,交互之间可以暂停、续播、回退、单步执行。
在 CSF Next 中编写带 step 的 story
CSF Next(preview.meta/meta.story结构)是片段中出现的另一种 story 组织方式。与 CSF 3 的export const风格不同,CSF Next 从.storybook/preview引入preview,先声明meta(绑定被测组件),再通过meta.story({...})定义单个 story:
import preview from '../.storybook/preview'; import MyComponent from './MyComponent'; const meta = preview.meta({ component: MyComponent, }); export const Submitted = meta.story({ play: async ({ args, canvas, step, userEvent }) => { await step('Enter email and password', async () => { await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); await userEvent.type(canvas.getByTestId('password'), 'supersecret'); }); await step('Submit form', async () => { await userEvent.click(canvas.getByRole('button')); }); }, });片段同时为多个渲染器提供了对应变体,差异集中在如何绑定组件以及文件命名上:
| 渲染器 | 组件绑定写法 | 建议文件名 |
|---|---|---|
| Angular | component: MyComponent(导入./my-component.component) | MyComponent.stories.ts |
| React | component: MyComponent(默认导出组件) | MyComponent.stories.ts/.stories.js |
| Vue 3 | component: MyComponent(导入./MyComponent.vue) | MyComponent.stories.ts/.stories.js |
| Web Components | component: 'my-component'(标签名字符串) | MyComponent.stories.ts/.stories.js |
例如 Web Components 变体将 meta 的组件绑定为标签名:
import preview from '../.storybook/preview'; const meta = preview.meta({ component: 'my-component', }); export const Submitted = meta.story({ play: async ({ args, canvas, step, userEvent }) => { await step('Enter email and password', async () => { await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); await userEvent.type(canvas.getByTestId('password'), 'supersecret'); }); await step('Submit form', async () => { await userEvent.click(canvas.getByRole('button')); }); }, });无论哪种渲染器与 story 组织方式,step('...', async () => {...})的核心用法完全一致:标签只是为交互分组服务,不改变事件执行顺序,也不改变 context 内容。
step 的源码实现与底层原理
从源码角度,step是 story 运行时 context 的一个正式成员。在 story.ts 的StoryContext接口中可以看到它与其他调试 API 并列:
export interface StoryContext<TRenderer, TArgs> ... { canvas: Canvas; userEvent: ReturnType<typeof userEvent.setup>; mount: TRenderer['mount']; step: StepFunction<TRenderer, TArgs>; ... }对应的类型定义在同一文件 story.ts:
StepLabel即普通字符串;StepFunction = (label: StepLabel, play: PlayFunction) => Promise<void> | void——这就是你在 play 函数里调用的step的签名;StepRunner = (label, play, context) => Promise<void>是各插件(尤其是 interactions 插件)对“如何运行一个 step”的底层扩展点。
step的执行并非魔法,而是由 step runner 编排的。仓库中 stepRunners.ts 提供了composeStepRunners:把多个插件注册的 step runner 像装饰器一样依次组合,最内层才真正执行用户传入的play(context)。默认情况下(没有任何插件注册 step runner),组合结果等价于async (label, play, context) => play(context),即 step 退化为普通函数调用。而典型的 step runner 实现来自 interactions 插件——它会对 step 内部的所有被插桩代码附加标签信息,这正是 Interactions 面板能渲染成带标题分组的原因。
这一组合在 composeConfigs.ts 中完成:
runStep: composeStepRunners<TRenderer>(stepRunners),其行为也有对应测试验证,见 stepRunners.test.ts,其中既有多个 step runner 依序嵌套的场景,也有空 runner 数组退化为直通执行的场景。
由此可以推断两点用法约束:
- 不要在 step 回调里省略
await:step runner 的职责之一是把回调内被插桩的交互完整记录,异步时序被await打断会破坏面板的步骤还原; - step 可以嵌套使用:因为 step 的内层回调收到的仍是完整
StoryContext(其中包含step本身),你可以在一个大的step('Submit form')内部再细分step('Fill email')、step('Click submit')等层级,从而获得更清晰的树状交互日志。
调试与运行带 step 的交互测试
在 Storybook UI 中打开某个 story 的 Interactions 面板,就能看到 play 函数按 step 分组后的完整流程;面板提供暂停、恢复、回退与逐条执行的控件。如果某个断言失败,错误会直接挂在对应的交互/分组上,配合 permalink(基于 URL 的复现链接)即可把失败现场分享给协作者,无需额外环境即可复现,参见 interaction-testing.mdx 调试章节。
自动执行方面,这些交互测试可通过 Vitest 插件在 Storybook UI、编辑器、终端或 CI 中运行,也可以使用 test-runner,具体方式见 运行交互测试 与 CI 章节。
最佳实践小结
- 按业务阶段而不是按单个操作建组:一个 step 内包含一组“用户视角下连贯的动作”,标签采用“动词 + 对象”的祈使句(如
Enter email and password、Submit form); - 查询顺序遵循 Testing Library 推荐优先级:能用
getByRole/getByLabelText就不用getByTestId; - 始终
awaitstep 与其内部的 userEvent/expect,保证 Interactions 面板日志的完整性与可调试性; - 善用 Interactions 面板做回归验证:把步骤标题当作测试文档,失败信息会让后续维护者第一时间理解组件预期行为;
- 若需要断言、mock 模块或在渲染前后执行逻辑,可与
fn、mount、beforeEach/afterEach等 API 组合使用,完整参考 interaction-testing.mdx。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考