尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Storybook 中通过 mock 模块的 spy 函数断言交互测试行为:以 saveNote 保存流程为例

Storybook 中通过 mock 模块的 spy 函数断言交互测试行为:以 saveNote 保存流程为例 Storybook 中通过 mock 模块的 spy 函数断言交互测试行为以 saveNote 保存流程为例【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybookStorybook 的交互测试Interaction Test允许在 story 的play函数中模拟用户行为并断言结果而模块级 mockModule Mocking则让你拦截组件依赖的本地模块与外部包。本文以saveNote保存流程为例讲解如何把被自动 mock 的模块当作 spy 函数在交互测试中验证“点击保存按钮后确实调用了saveNote”并深入仓库源码揭示其底层实现原理。一、场景与核心思路组件往往依赖其他模块本地工具函数、外部包等。当你在 Storybook 中渲染或测试组件时希望隔离这些依赖既能控制它们的行为返回固定的 mock 数据又能断言它们是否被调用、以什么参数调用。Storybook 的 automocking自动模块 mock机制把这一过程简化为两步注册在项目级配置.storybook/preview.*中用sb.mock()注册要 mock 的模块使用在 story 中直接导入该模块导入得到的即为 mock 函数可以配置返回值、也可以直接对它做断言。而“断言模块被调用”正是把模块 mock 与交互测试结合起来的典型玩法expect(saveNote).toHaveBeenCalled()。本文主文档片段 storybook-test-fn-mock-spy.md 同时被 交互测试指南 与 模块 mock 指南 引用分别从“交互测试断言”与“模块 mock 使用”两个角度讲解这一模式。二、注册模块sb.mock()的三种形态automocking 的第一步是在.storybook/preview.*中注册模块。根据你的需要sb.mock()有三种用法完整示例见 automock-register-spy.md、automock-register-full.md 与 automock-register-mock-file.md形态配置行为spy-only推荐sb.mock(import(../lib/session.ts), { spy: true })保留原模块功能仅在其上附加 spy 能力可断言调用、按需覆盖行为完全 automocksb.mock(import(../lib/session.ts))spy默认false将所有导出替换为 Vitest mock 函数原始函数体不会执行mock 文件sb.mock(import(../lib/session.ts)) 对应__mocks__/session.js用自写的 mock 文件替换模块适合复杂行为或跨 story 复用// .storybook/preview.ts import type { Preview } from storybook/your-framework; import { sb } from storybook/test; // 对本地模块的所有导出附加 spy sb.mock(import(../lib/session.ts), { spy: true }); // 对 node_modules 中的包附加 spy sb.mock(import(uuid), { spy: true }); const preview: Preview { /* ... */ }; export default preview;注册时需要注意几个关键约束来自 模块 mock 指南sb.mock()只能在项目级.storybook/preview.*中调用story 文件中不能注册新 mock但可以修改已注册 mock 的行为本地模块的路径必须相对.storybook/preview.*文件、必须带文件扩展名如.ts、不能使用别名或 subpath如/lib/session.ts使用 TypeScript 时推荐用import(../lib/session.ts)形式包裹路径以保证类型解析正确Webpack 构建器只能 automock 仅提供 ESModulesESM入口的node_modules包CJS 包需要用 mock 文件方式。三、核心示例点击保存后断言saveNote被调用注册完成后在 story 中按普通方式导入该模块即可拿到 mock 函数然后在play函数里结合canvas查询、userEvent交互与expect断言完成测试。以下是保存流程的完整示例主文档片段原文Angular CSF 3 版本// NoteUI.stories.ts (Angular, CSF 3) import type { Meta, StoryObj } from storybook/angular; import { expect } from storybook/test; // Automocked module resolves to ../app/__mocks__/actions import { saveNote } from ../app/actions; import { createNotes } from ../app/mocks/notes; import NoteUI from ./note-ui.component; const meta: MetaNoteUI { component: NoteUI }; export default meta; type Story StoryObjNoteUI; const notes createNotes(); export const SaveFlow: Story { name: Save Flow ▶, args: { isEditing: true, note: notes[0], }, play: async ({ canvas, userEvent }) { const saveButton canvas.getByRole(menuitem, { name: /done/i }); await userEvent.click(saveButton); // This is the mock function, so you can assert its behavior await expect(saveNote).toHaveBeenCalled(); }, };这段代码展示了交互测试的完整套路准备数据通过createNotes()生成 mock 笔记数据通过args把组件置于“编辑中”的初始状态isEditing: true查询元素canvas.getByRole(menuitem, { name: /done/i })用可访问角色查询“完成”按钮。这里刻意使用了role 查询而非data-testid因为这与真实用户感知 UI 的方式一致可访问性最佳详见交互测试指南中的查询类型表模拟交互await userEvent.click(saveButton)模拟点击。注意userEvent方法必须await否则无法在 Interactions 面板中正确记录与调试断言调用await expect(saveNote).toHaveBeenCalled()。由于saveNote是被 automock 的模块导出它是一个完整的 Vitest mock 函数因此可以像断言 spy 一样断言它被调用。expect与userEvent一样在play函数中必须await这是为了让断言能被 Interactions 面板正确记录交互测试指南。expect组合了 Vitest 的expect与testing-library/jest-dom的匹配器常用的断言包括匹配器作用expect(spy).toHaveBeenCalled()断言 spy 函数被调用过expect(spy).toHaveBeenCalledWith(example)断言 spy 以特定参数被调用expect(el).toBeInTheDocument()断言元素在 DOM 中expect(el).toBeVisible()断言元素对用户可见四、CSF 变体与多框架写法同样的测试模式可以写成不同 CSF 版本与不同渲染器。主文档片段覆盖了 CSF 3、CSF Next 实验语法以及 Svelte CSF下面列出代表性变体其余版本仅导入路径或 meta 形式不同。CSF NextReact / Vue / Web Components 等CSF Next 通过preview.meta()与preview.story()组织故事不再需要显式的Meta/StoryObj类型// NoteUI.stories.ts (React, CSF Next ) import { expect } from storybook/test; import preview from ../.storybook/preview; // Automocked module resolves to ../app/__mocks__/actions import { saveNote } from ../app/actions; import { createNotes } from ../app/mocks/notes; import NoteUI from ./note-ui; const meta preview.meta({ component: NoteUI }); const notes createNotes(); export const SaveFlow meta.story({ name: Save Flow ▶, args: { isEditing: true, note: notes[0], }, play: async ({ canvas, userEvent }) { const saveButton canvas.getByRole(menuitem, { name: /done/i }); await userEvent.click(saveButton); // This is the mock function, so you can assert its behavior await expect(saveNote).toHaveBeenCalled(); }, });Vue 版本除将NoteUI替换为./note-ui.vue外完全一致Web Components 版本则将component写成标签名note-ui且没有组件导入语句。Svelte CSFSvelte 项目使用storybook/addon-svelte-csf的defineMetaStory组件语法play 函数写在Story的play属性中!-- NoteUI.stories.svelte (Svelte CSF) -- script module import { defineMeta } from storybook/addon-svelte-csf; import { expect } from storybook/test; // Automocked module resolves to ../app/__mocks__/actions import { saveNote } from ../app/actions; import { createNotes } from ../app/mocks/notes; import NoteUI from ./note-ui.svelte; const { Story } defineMeta({ title: Mocked/NoteUI, component: NoteUI, }); /script script const notes createNotes(); /script Story nameSave Flow ▶ args{{ isEditing: true, note: notes[0] }} play{async ({ canvas, userEvent }) { const saveButton canvas.getByRole(menuitem, { name: /done/i }); await userEvent.click(saveButton); // This is the mock function, so you can assert its behavior await expect(saveNote).toHaveBeenCalled(); }} /通用 CSF 3Common Renderer对于 React、Vue、Svelte 等渲染器通用的写法如下TypeScript 版JS 版去掉类型标注即可// NoteUI.stories.ts (common, CSF 3) // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { Meta, StoryObj } from storybook/your-framework; import { expect } from storybook/test; // Automocked module resolves to ../app/__mocks__/actions import { saveNote } from ../app/actions; import { createNotes } from ../app/mocks/notes; import NoteUI from ./note-ui; const meta { component: NoteUI } satisfies Metatypeof NoteUI; export default meta; type Story StoryObjtypeof meta; const notes createNotes(); export const SaveFlow: Story { name: Save Flow ▶, args: { isEditing: true, note: notes[0], }, play: async ({ canvas, userEvent }) { const saveButton canvas.getByRole(menuitem, { name: /done/i }); await userEvent.click(saveButton); // This is the mock function, so you can assert its behavior await expect(saveNote).toHaveBeenCalled(); }, };五、控制 mock 行为mocked()、beforeEach与 mock 函数方法除了断言“被调用”交互测试通常还需要 mock 模块返回确定的值以保证测试可重复。主文档片段配套的 automocked-modules-in-story.md 展示了标准做法在 story或组件级beforeEach中设置返回值。import { expect, mocked } from storybook/test; import { AuthButton } from ./AuthButton; import { v4 as uuidv4 } from uuid; import { getUserFromSession } from ../lib/session; const meta { component: AuthButton, // This will run before each story is rendered beforeEach: async () { mocked(uuidv4).mockReturnValue(1234-5678-90ab-cdef); mocked(getUserFromSession).mockReturnValue({ name: John Doe }); }, } satisfies Metatypeof AuthButton; export default meta; export const LogIn: Story { play: async ({ canvas, userEvent }) { const button canvas.getByRole(button, { name: Sign in }); userEvent.click(button); // Assert that the getUserFromSession function was called expect(getUserFromSession).toHaveBeenCalled(); }, };sb.mock()产生的都是完整的 Vitest mock 函数因此可以使用其全部方法。最常用的三个方法作用mockReturnValue(value)设置同步返回值mockResolvedValue(value)设置异步函数 resolve 的值mockImplementation(fn)设置自定义实现TypeScript 项目推荐用storybook/test导出的mocked()工具包裹 mock 函数它是vi.mocked的类型安全封装以获得正确的类型推断模块 mock 指南。需要注意的是你不需要手动清理fn()mock。Storybook 在渲染每个 story 前会自动恢复 mock可通过parameters.test.restoreMocks配置控制这保证了 story 之间的隔离性。六、底层原理构建期静态替换零运行时开销automocking 之所以能在 dev 模式与 build 模式都稳定工作是因为所有 mock 决策都在构建期完成。核心实现在 Vite 插件的 vite-mock/plugin.tsbuildStart阶段扫描调用extractMockCalls()解析.storybook/preview.*中的sb.mock()调用生成 mock 清单load/transform阶段替换如果存在对应的__mocks__重定向文件call.redirectPath直接加载该文件内容否则调用getAutomockCode(code, call.spy)在构建期改写原模块源码把导出替换为 mock 或 spystorybook:mock-loader-preview子插件还会把sb.mock(import(...))调用重写为可执行的注册代码rewriteSbMockImportCallsdev 模式热失效configureServer中监听preview文件与__mocks__目录的变化一旦 mock 增删改就通过invalidateModule失效受影响的模块图并触发浏览器整页刷新full-reload实现快速交互开发。由于替换发生在构建期最终产物中直接打包了 mock 版本模块没有运行时拦截逻辑也就没有运行时性能开销。这一点与 Vitest 传统的按测试动态 mock 有本质区别详见模块 mock 指南的“How it works”一节作用域mock 全局定义且只能在.storybook/preview.*中注册不能在单个 story 文件中调用sb.mock()静态设计所有 mock 决策在构建期定型没有sb.unmock()之类的运行时 API但你可以用mocked(myFunction).mockReturnValue(new value)在play函数或beforeEach中动态修改行为无工厂函数sb.mock()不接受第二个工厂参数如sb.mock(path, () ({...}))因为工厂在运行时执行与构建期决策相矛盾。仓库的 ModuleMocking.stories.ts 是一个可运行的 automocking 端到端示例Originalstory 断言fn被调用且渲染结果不含 mock 值Mockedstory 通过mocked(fn).mockReturnValue([mocked value])覆盖返回值并断言渲染结果包含Function: mocked value。它同时验证了“运行时控制行为 构建期替换”的组合能力。七、Webpack 构建器的注意事项如果你使用 Webpack 构建器而非 Viteautomocking 有额外限制模块 mock 指南的 Troubleshooting只能 automock 具有纯 ESM 入口的node_modules包同时提供 CJS 与 ESM 入口的包无法被 Webpack 正确解析出 ESM 入口因此不能 automock必须改用 mock 文件如果强行 automock CJS 模块可能遇到exports is not defined错误若已用 Jest 等其他测试工具配置了 mock注意避免与 Storybook 的 mock 系统对同一模块重复 mock 导致冲突。八、运行与调试写好上述 story 后可以用以下方式运行交互测试Storybook UI在侧边栏展开测试组件并点击Run component tests按钮或使用 story/文件夹上下文菜单配合 Interactions 面板逐步查看play函数中的每一步交互与断言交互测试指南Vitest addon可在编辑器、CLI 与 CI 环境运行详见 Vitest addon 集成指南test-runner不使用 Vitest addon 时可在终端或 CI 中通过 test-runner 运行。小结把模块 mock 与交互测试组合是 Storybook 测试体系中高性价比的一环sb.mock()在构建期完成模块替换与 spy 注入play函数中的canvasuserEventexpect三件套模拟真实用户操作并断言结果mocked()mockReturnValue等方法控制 mock 行为。主文档片段给出的expect(saveNote).toHaveBeenCalled()正是这一组合的最小闭环可以直接迁移到你的任何组件React、Vue、Svelte、Angular、Web Components中复用。【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表