首页
/ Storybook 项目中的 expect 使用规范:深入解读 use-storybook-expect 规则

Storybook 项目中的 expect 使用规范:深入解读 use-storybook-expect 规则

2026-09-06 18:20:26作者:幸俭卉

本文系统讲解 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 为基础,依次注册 JestExtendJestChaiExpectJestAsymmetricMatchers,拼装出一套 Jest 兼容的断言 API(支持 toEqualtoHaveBeenCalledtoHaveBeenCalledOnce 等断言),并额外混入 @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-interactionsrecommended 同时包含其 flat/ 版本配置

该规则被纳入插件提供的以下配置集(见文档开头的 RULE-CATEGORIES 标记以及 code/lib/eslint-plugin/README.md 中的规则清单表格):

  • addon-interactionsflat/addon-interactions(使用 @storybook/addon-interactions 写交互测试时的推荐配置);
  • recommendedflat/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 语句是否满足两个条件:

  1. 来源包名命中白名单:'@storybook/jest''@storybook/test''storybook/test' 三者之一;
  2. 导入说明符中存在具名导入 expect(通过 isImportSpecifierspec.imported.name === 'expect' 判断)。

只要命中,就会把文件级标志 isImportingFromStorybookExpect 置为 true。值得注意:合法来源是三个包,虽然规则名与报错消息沿用了最早的 @storybook/jest 命名,但代码层面 storybook/test@storybook/test 同样被认可(详见 create-storybook-rule.tscreateStorybookRule 对规则上下文的封装)。

第二步:收集所有 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/teststorybook/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.tscode/core/src/test/index.ts 中进一步研读;规则的完整测试用例则收录于 code/lib/eslint-plugin/src/rules/use-storybook-expect.test.ts。对希望通过 ESLint 在团队内统一 Storybook 编码规范、保障交互测试在浏览器中稳定运行的工程团队而言,这是成本最低、收益最直接的规则之一。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388