Storybook 项目中的 expect 使用规范:深入解读 use-storybook-expect 规则
本文系统讲解 eslint-plugin-storybook 插件中的 use-storybook-expect 规则。该规则强制在 Story 的 play 交互函数中使用从 storybook/test(或 @storybook/test、@storybook/jest)导入的 expect,而不是依赖 Node 环境(如 Jest、Vitest)注入的全局 expect。读完本文,你将掌握该规则的全部配置细节、底层 AST 检测原理,以及在实际 Storybook 项目中正确落地此规范的方法。文章对应的规则文档位于 code/lib/eslint-plugin/docs/rules/use-storybook-expect.md。
为什么交互断言必须使用 Storybook 的 expect
Storybook 的 play 函数是在浏览器环境中执行的。你可以在 play 中使用真实用户行为(点击、输入等),并断言组件的行为与状态是否符合预期。
问题在于:在组件测试或单元测试中习惯使用的全局 expect(例如通过 Jest 的 testEnvironment、Vitest 的 globals 注入的全局断言函数)只在 Node 测试运行时可用。当同一个 Story 文件被 Storybook 在浏览器中渲染、play 函数被真实执行时,这些全局变量并不存在,直接调用会抛出 ReferenceError,导致交互测试在浏览器端崩溃。
为解决这一问题,Storybook 提供了浏览器兼容版本的 expect:
- 当前通过
storybook/test包(对应仓库中的 code/core/src/test 目录)提供; - 早期版本则由
@storybook/jest(现已被视为 legacy)提供。
在 code/core/src/test/index.ts 中可以看到,storybook/test 导出的 expect 实际是对浏览器端实现执行了 instrument() 包装的结果。其底层实现在 code/core/src/test/expect.ts:以 chai 为基础,依次注册 JestExtend、JestChaiExpect、JestAsymmetricMatchers,拼装出一套 Jest 兼容的断言 API(支持 toEqual、toHaveBeenCalled、toHaveBeenCalledOnce 等断言),并额外混入 @testing-library/jest-dom/matchers 提供的 DOM 匹配器,最终通过 Object.defineProperty(globalThis, ...) 在浏览器全局环境中完成注册。关于该包的定位与完整用法,可阅读 code/core/src/test/README.md。
// Button.stories.ts
import { expect, fn, userEvent, within } from 'storybook/test';
import { Button } from './Button';
export default {
component: Button,
args: {
onClick: fn(),
},
};
export const Demo = {
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(args.onClick).toHaveBeenCalled();
},
};
正是这种"写交互断言时必须用 Storybook 自带 expect"的约定,构成了 use-storybook-expect 规则的检查目标。
规则详情:何时会报错
use-storybook-expect 规则的核心约定是:在 Story 文件中编写 play 交互并对值进行断言时,必须使用从 Storybook 测试库导入的 expect。
错误示例
下面的写法使用了 Jest 注入的全局 expect。虽然在单元测试运行器中可能通过,但在浏览器中执行 play 时会直接失败:
Default.play = async () => {
// 使用来自 Jest 的全局 expect,在浏览器中会失败
await expect(123).toEqual(123);
};
正确示例
// 正确:显式导入。
import { expect } from 'storybook/test';
// 或者下面这种,目前被视为 legacy 写法
import { expect } from '@storybook/jest';
Default.play = async () => {
// 使用从 storybook 包导入的 expect
await expect(123).toEqual(123);
};
内置规则元信息
从规则源码 code/lib/eslint-plugin/src/rules/use-storybook-expect.ts 可以看到该规则在插件内的元数据定义:
| 字段 | 值 | 说明 |
|---|---|---|
| 规则名 | use-storybook-expect |
完整引用名 storybook/use-storybook-expect |
| 类型 | suggestion |
建议型规则,用于在 play 函数等代码中提示正确写法 |
| 消息 | useExpectFromStorybook |
提示语:"不要在 Story 中直接使用全局 expect,应当从 @storybook/test(首选)或 @storybook/jest 导入" |
| 所属分类 | addon-interactions、recommended |
同时包含其 flat/ 版本配置 |
该规则被纳入插件提供的以下配置集(见文档开头的 RULE-CATEGORIES 标记以及 code/lib/eslint-plugin/README.md 中的规则清单表格):
addon-interactions与flat/addon-interactions(使用 @storybook/addon-interactions 写交互测试时的推荐配置);recommended与flat/recommended(插件默认推荐配置)。
底层实现:该规则是如何检测的
use-storybook-expect 并不做复杂的类型推断,而是一个基于 AST 的轻量启发式检测。结合源码 code/lib/eslint-plugin/src/rules/use-storybook-expect.ts 与其单元测试 code/lib/eslint-plugin/src/rules/use-storybook-expect.test.ts,可以还原其完整检测链路:
第一步:识别合法的 expect 来源
规则在遍历到 ImportDeclaration 节点时,检查该 import 语句是否满足两个条件:
- 来源包名命中白名单:
'@storybook/jest'、'@storybook/test'、'storybook/test'三者之一; - 导入说明符中存在具名导入
expect(通过isImportSpecifier与spec.imported.name === 'expect'判断)。
只要命中,就会把文件级标志 isImportingFromStorybookExpect 置为 true。值得注意:合法来源是三个包,虽然规则名与报错消息沿用了最早的 @storybook/jest 命名,但代码层面 storybook/test 与 @storybook/test 同样被认可(详见 create-storybook-rule.ts 中 createStorybookRule 对规则上下文的封装)。
第二步:收集所有 expect 调用
规则监听 CallExpression,当调用表达式的 callee 是标识符 expect(即形如 expect(...) 的调用)时,将该 callee 节点压入 expectInvocations 列表。它并不关心调用发生在 play 函数体内还是其他辅助函数中,只要是文件内出现的 expect(...) 调用都会被捕获。
第三步:在 Program:exit 统一裁决
文件遍历结束后,在 Program:exit 钩子中执行最终判断:如果文件没有从上述任意一个 Storybook 包导入 expect,同时文件中又存在至少一次 expect(...) 调用,则将每一处调用都 context.report() 上报,抛出 useExpectFromStorybook 错误消息。
也就是说,该规则的判断模型可以概括为:"文件里有没有引入 Storybook 的 expect" × "文件里有没有调用 expect"。这也解释了单元测试中的几个边界用例:
- 通过(valid):从
@storybook/jest、@storybook/test或storybook/test中任一路径导入expect后,无论在顶层Default.play还是在组合导出(export const Basic = { ...Default, play: ... })中使用expect(123).toEqual(123)都不再报错; - 报错(invalid):完全没有导入而直接调用全局
expect;把调用放进外部辅助函数(如const someInteraction = () => { expect(123).toEqual(123); })再在play里引用,同样会因文件级标志为假而被捕获。
从源码结构看,这是一个"文件粒度"的粗粒度检测:它没有基于作用域解析去区分 expect 的具体绑定来源,而是以"是否出现过合法导入"作为整份文件的放行条件。这一点在理解规则的覆盖范围与偶发漏报/误报时非常重要。
规则归属的配置集与其定位
use-storybook-expect 是插件内与"交互测试"强相关的规则族之一,与之配套的还有:
storybook/await-interactions:要求对交互调用使用await;storybook/context-in-play-function:调用其他 story 的play时必须传入 context;storybook/use-storybook-testing-library:禁止在 Story 中直接使用 testing-library(应使用storybook/test的 instrumented 版本)。
这些规则共同构成 addon-interactions 配置集(也是 recommended 配置的一部分),服务于"交互测试代码在浏览器端安全可执行"这一目标。规则完整清单与所属配置见 code/lib/eslint-plugin/README.md。
在项目中启用与禁用该规则
通过推荐配置启用
use-storybook-expect 默认包含在 recommended / addon-interactions 配置集中,按 code/lib/eslint-plugin/README.md 的说明配置即可自动生效。
使用传统 .eslintrc(ESLint < v9)时:
{
"extends": ["plugin:storybook/recommended"]
}
插件只对 *.stories.*(推荐)或 *.story.* 文件自动生效,因此配置了 recommended 后,Story 文件中的非法 expect 调用就会按 error 级别被报告。
使用 ESLint v9 的 flat config(eslint.config.js)时:
import storybook from 'eslint-plugin-storybook';
export default [
// ...其他通用配置
...storybook.configs['flat/recommended'],
];
显式覆盖或关闭规则
如果你使用了 tseslint 等工具函数,需注意将 storybook 配置作为一个整体传入(不要解构):
import storybook from 'eslint-plugin-storybook';
import tseslint from 'typescript-eslint';
export default tseslint.config(
somePlugin,
storybook.configs['flat/recommended'] // 注意此处未解构
);
需要单独调整该规则时,推荐使用 overrides(.eslintrc)或将规则段放在 flat config 的对应文件匹配中,把改动范围限定在 Story 文件,避免误伤普通源码:
{
"overrides": [
{
// 请与 .storybook/main.js 中 stories 的配置保持一致
"files": ['**/*.stories.@(ts|tsx|js|jsx|mjs|cjs)'],
"rules": {
"storybook/use-storybook-expect": "error", // 或 "warn" / "off"
// 其他示例
"storybook/default-exports": "off"
}
}
]
}
何时不使用本规则
如规则文档"When Not To Use It"一节所指明,该规则不应被应用到测试文件(如 *.test.js、*.spec.ts)。原因很直接:在纯测试文件中使用测试框架的全局 expect 是完全正当的,那里的断言本来就在 Node 运行器中执行,不需要也不可能引用浏览器端的 expect 实现。因此,请务必确保 Storybook 规则只针对 Story 文件启用(例如借助 .stories.* 的文件匹配或上述 overrides 限定),这样既能享受 Story 文件内的强约束,又不会打扰常规测试文件。
结语
use-storybook-expect 规则从根因上规避了一类高频且隐蔽的错误:把 Node 测试环境的全局断言习惯带入浏览器端执行的 play 函数。通过"文件级导入标志 + expect 调用收集"这一轻量 AST 策略,它在推荐配置与 addon-interactions 配置下默认开启,以 error 级别提示开发者改从 storybook/test(首选)或 legacy 的 @storybook/jest 导入 expect。底层配套的浏览器兼容断言实现,可在 code/core/src/test/expect.ts 与 code/core/src/test/index.ts 中进一步研读;规则的完整测试用例则收录于 code/lib/eslint-plugin/src/rules/use-storybook-expect.test.ts。对希望通过 ESLint 在团队内统一 Storybook 编码规范、保障交互测试在浏览器中稳定运行的工程团队而言,这是成本最低、收益最直接的规则之一。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00