Storybook Automocking 系列教程:在 .storybook/preview.* 中用sb.mock注册 Mock 文件(register mock file)
本篇技术指南聚焦 Storybook 自动模拟(Automocking)三种注册方式中的Mock 文件(Mock files)模式:如何在项目级配置.storybook/preview.*中通过storybook/test的sb.mock工具注册本地模块与node_modules包的自定义 mock 文件,从而实现复杂、可复用、可跨 story 共享的模块替换。读完本文将掌握 mock 文件目录布局、sb.mock各语法形态(CSF 3 与 CSF Next、TS 与 JS)、路径解析规则,以及底层源码的运行原理。
背景:Automocking 的两种能力来源
Storybook 在storybook/test中提供sb.mock()工具注册需要模拟的模块。同一套 API 支持两种替换来源:
- 自动生成 mock/spy:当未找到对应 mock 文件时,Storybook 在构建期自动把原模块的导出替换为 Vitest mock 函数;
- Mock 文件(本文主题):开发者预先编写一个真实的替换文件(通常放在
__mocks__目录),sb.mock会优先查找并使用该文件。
正如 mocking-modules.mdx 中的说明:注册自动 mock 与注册 mock 文件的 API 完全一致,唯一区别是sb.mock会先在对应目录查找是否存在 mock 文件(redirect),找不到才回退到自动 mock。
为什么需要 mock 文件?当模拟逻辑较复杂、或需要在多个 story 间复用一个 mock 行为时,把模拟实现写进独立文件更清晰;同时 mock 文件能彻底阻断原模块代码执行——这一点与"完全自动 mock"不同,后者虽然会替换导出函数,但模块自身及其依赖仍会被求值(相关说明)。
第一步:按规则创建 Mock 文件
本地模块的 mock 文件
针对项目内的本地模块,在与模块同级的__mocks__目录下创建同名文件。例如要模拟lib目录下的session模块:
lib/ ├── session.ts └── __mocks__/ └── session.tsexport function getUserFromSession() { return { name: 'Mocked User' }; }node_modules 包的 mock 文件
针对外部依赖包,在项目根目录的__mocks__目录创建 mock 文件。例如模拟uuid包:
__mocks__/ └── uuid.jsexport function v4() { return '1234-5678-90ab-cdef'; }若外部模块带深层导入路径(如lodash-es/add),需要按路径层级建目录,例如__mocks__/lodash-es/add.js。
"项目根目录"随构建器不同而不同
| 构建器 | 根__mocks__目录位置 |
|---|---|
| Vite | Vite 配置中的root目录(通常为process.cwd());若无法解析则回退到包含.storybook的目录 |
| Webpack | Webpack 配置中的context目录(通常为process.cwd());若无法解析则回退到仓库根目录 |
Mock 文件硬性要求
- 必须用JavaScript编写(不能用 TypeScript),且使用ESModules(不能用 CJS);
- 必须与原模块导出同名的命名导出(named exports);如需模拟默认导出,可在 mock 文件中使用
export default。
第二步:在.storybook/preview.*注册 Mock 文件
注册 mock 文件的调用放在项目级配置.storybook/preview.tsx(或.jsx/.ts/.js)中,这正是本文目标代码片段 automock-register-mock-file.md 的核心内容。它同时替换两处依赖:本地模块../lib/session.ts与外部包uuid。
CSF 3 形态(TypeScript)
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, vue3-vite, sveltekit) import type { Preview } from '@storybook/your-framework'; import { sb } from 'storybook/test'; // 👇 Replaces imports of this module with imports to `../lib/__mocks__/session.ts` sb.mock(import('../lib/session.ts')); // 👇 Replaces imports of this module with imports to `../__mocks__/uuid.ts` sb.mock(import('uuid')); const preview: Preview = { // ... }; export default preview;CSF 3 形态(JavaScript)
import { sb } from 'storybook/test'; // 👇 Replaces imports of this module with imports to `../lib/__mocks__/session.ts` sb.mock('../lib/session.js'); // 👇 Replaces imports of this module with imports to `../__mocks__/uuid.ts` sb.mock('uuid'); export default { // ... };CSF Next 形态(definePreview)
使用新实验性 CSF Next 语法时,改为从框架包导入definePreview,sb.mock调用体不变。以 React 与 Vue 为例:
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) import { definePreview } from '@storybook/your-framework'; import { sb } from 'storybook/test'; // 👇 Replaces imports of this module with imports to `../lib/__mocks__/session.ts` sb.mock(import('../lib/session.ts')); // 👇 Replaces imports of this module with imports to `../__mocks__/uuid.ts` sb.mock(import('uuid')); export default definePreview({ // ... });import { definePreview } from '@storybook/vue3-vite'; import { sb } from 'storybook/test'; sb.mock(import('../lib/session.ts')); sb.mock(import('uuid')); export default definePreview({ // ... });CSF Next 形态下definePreview的导入源随渲染器切换,代码骨架完全相同:
| 渲染器 | definePreview 导入源 |
|---|---|
| React | @storybook/your-framework(react-vite、nextjs、nextjs-vite 等) |
| Vue | @storybook/vue3-vite |
| Angular | @storybook/angular |
| Web Components | @storybook/web-components-vite |
两行注释意味着什么
上面示例里的两行注释揭示了一条关键事实:mock 文件路径并非由开发者显式写出。sb.mock(import('../lib/session.ts'))指向的是待模拟的原模块,系统会自动到其相邻的__mocks__目录查找替换文件;sb.mock(import('uuid'))则会到项目根__mocks__下查找uuid.js。这就是文档所称"API 相同、仅查找顺序不同"的实现形态。
sb.mock注册的核心约束(务必遵守)
围绕注册 mock 文件与自动 mock,sb.mock的规则是相同的(mocking-modules.mdx):
- 注册位置:只能在项目级配置
.storybook/preview.*中注册。story 文件中不能调用sb.mock注册新模块,只能通过beforeEach/play等运行时修改已注册 mock 的行为; - 可注册对象:本地模块(如
../lib/session.ts)与node_modules包(如uuid)均可; - 本地模块的路径要求:
- 不得使用别名或 subpath(如
@/lib/session.ts、#lib/session); - 必须相对于
.storybook/preview.*文件本身; - 必须包含文件扩展名(如
.ts或.js);
- 不得使用别名或 subpath(如
- TypeScript 推荐写法:用
import()包裹模块路径,如sb.mock(import('../lib/session.ts')),以保证模块能被正确解析与获得类型; - Webpack 用户的额外限制:Webpack 构建器只能自动 mock 拥有纯 ESM 入口的
node_modules包。若包同时提供 CJS 与 ESM 入口,Webpack 无法正确解析 ESM 入口,此时应改用 mock 文件方式(automock-register-mock-file的场景正是解决此问题的推荐路径)。若强行走自动 mock,常见报错为exports is not defined。
在 Story 中控制 Mock 文件的行为并断言
mock 文件导出的函数会被注册为完整的 Vitest mock 函数,因此可在 story 的beforeEach(在渲染前执行)或play中设置返回值并断言调用。注意此时应使用storybook/test的mocked工具获取正确的 TS 类型(它是vi.mocked的类型安全封装):
import type { Meta, StoryObj } from '@storybook/your-framework'; 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, // 👇 Runs before each story renders beforeEach: async () => { // 👇 Force known, consistent behavior for mocked modules mocked(uuidv4).mockReturnValue('1234-5678-90ab-cdef'); mocked(getUserFromSession).mockReturnValue({ name: 'John Doe' }); }, } satisfies Meta<typeof AuthButton>; export default meta; type Story = StoryObj<typeof 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(); }, };由于 mock 文件提供的是完整 Vitest mock 函数,最常用的方法包括:
| 方法 | 用途 |
|---|---|
mockReturnValue(value) | 设定同步返回值 |
mockResolvedValue(value) | 设定异步函数 resolve 的值 |
mockImplementation(fn) | 设定自定义实现 |
无需手工清理这些 mock:Storybook 在渲染每条 story 前会自动恢复 mock(对应parameters.test.restoreMocks行为)。
源码级原理:sb.mock如何找到并注入 Mock 文件
从本仓库源码可以进一步印证注册机制的两段式处理。
阶段一:解析与重定向(resolve)
在 code/core/src/mocking-utils/resolve.ts 中,resolveMock()(resolve.ts#L54-L79)先判断模块是否为外部包:
- 外部包通过
resolveExternalModule()解析——使用oxc-resolver,条件为browser/import/module/default,优先命中exports映射与package.json的browser字段(resolve.ts#L15-L36); - 本地模块则用
require.resolve(path, { paths: [dirname(importer)] })以preview 文件所在目录为基准解析(印证了"相对.storybook/preview.*"的约束); - 随后调用
findMockRedirect在对应__mocks__目录查找替换文件,得到redirectPath;找不到时为null,从而回退到自动 mock。
阶段二:构建期代码变换(automock / autospy)
当没有 mock 文件时,构建器会改写原模块导出。在 code/core/src/mocking-utils/automock.ts 中,getAutomockCode()(automock.ts#L17-L22)调用基于 MagicString 的automockModule():
- 解析原模块 AST,收集所有命名导出(函数、变量、类、重导出与默认导出);
- 通过
globalThis['__vitest_mocker__']访问 Vitest mock 注册表,调用mockObject(module, 'automock' | 'autospy')生成替换后的模块对象; autospy对应{ spy: true }(保留原行为),automock对应默认的完全替换;- 生成的新模块再以
export { ... }重新声明。因此所有替换决策在构建期静态完成,产物直接内联真正的 mock 模块,无运行时拦截开销。
这套变换逻辑被 vite-mock 插件(扫描.storybook/preview.*中的sb.mock()调用)与 Webpack 侧 webpack-automock-loader 复用。开发模式下,mock 文件的新增/变更还会通过 Vite 模块图失效机制触发热更新。
与 Vitest mocking 的差异
仓库文档明确强调其与 Vitest 原生 mocking 的差异(mocking-modules.mdx):mock 全局化且仅限.storybook/preview.*;决策静态化于构建期,因此没有sb.unmock(),也不接受工厂函数(sb.mock('path', () => ({...})))——工厂函数是运行时行为,与构建期静态替换矛盾。mock 文件的函数行为仍可在 story 的play/beforeEach中运行时修改。
仓库内的自动化验证
本仓库自带的示例与测试可以佐证上述全部机制:
- ModuleAutoMocking.stories.ts 与其mocks目录下的工具 mock,演示本地模块注册 mock 文件后的 story 编写方式;
- NodeModuleMocking.stories.js 演示
node_modules包级 mock; - 端到端层面 sb-module-mocking.spec.ts 覆盖了模块模拟的完整流程。
小结
Mock 文件是 Storybook Automocking 中最灵活的一种注册形态:本地模块把替换文件放在模块相邻的__mocks__目录,外部包把替换文件放在项目根__mocks__目录;随后在.storybook/preview.*用一行sb.mock(import('../lib/session.ts'))或sb.mock(import('uuid'))完成全局注册,mock 文件导出即成为可断言、可复用的 Vitest mock 函数。理解"先查 mock 文件、后自动 mock"的分流逻辑与构建期静态替换的机制,能帮助你在 Vite/Webpack 项目中稳定地隔离组件的外部依赖。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考