news 2026/9/8 23:36:13

@cypress/vite-plugin-cypress-esm 深入解析:让 Vite 驱动的 Cypress 组件测试中的 ESM 模块可被 stub 与 spy

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
@cypress/vite-plugin-cypress-esm 深入解析:让 Vite 驱动的 Cypress 组件测试中的 ESM 模块可被 stub 与 spy

@cypress/vite-plugin-cypress-esm 深入解析:让 Vite 驱动的 Cypress 组件测试中的 ESM 模块可被 stub 与 spy

【免费下载链接】cypressFast, easy and reliable testing for anything that runs in a browser.项目地址: https://gitcode.com/GitHub_Trending/cy/cypress

Cypress 组件测试中的cy.stub()/cy.spy()需要改写模块命名空间的成员,而 ES Module 规范强制命名空间被“密封(sealed)”,导致 mock 在浏览器端无从下手。@cypress/vite-plugin-cypress-esm(本仓库位于 npm/vite-plugin-cypress-esm)通过服务端 Vite 转换 + 浏览器端Proxy包装两层协作来解决这一矛盾。读完本文,你将掌握该插件的安装与集成方式、ignoreModuleListignoreImportList两类逃生配置的用法、底层重写与代理原理,以及其已知边界与排障方法。

问题背景:为什么组件测试需要这个插件

ESM 规范(ECMA-262 的模块章节)要求运行时将模块命名空间视为“密封”对象,禁止任何对命名空间成员的修改,这会带来安全与性能收益。但对于测试而言,它恰恰阻止了 mock 库用替换命名空间成员的方式注入假实现。Cypress 内部正是基于 Sinon 来实现cy.stubcy.spy,因此组件测试在 Vite 环境里默认无法直接对 ESM 导出的函数、组件进行打桩。

该插件通过在服务端把模块导入拦截并重写,在客户端把所有模块包装进一个特殊的Proxy实现中,使原本只读的 ESM 模块命名空间变得可改写,从而让cy.stub()cy.spy()能够正常工作。需要注意,该包当前处于alpha 预发布阶段(AGENTS.md 与 README 均明确标注),API 与行为可能变化,README 建议生产使用前应接受潜在的不稳定性。

架构总览:两层分工

从 AGENTS.md 与目录结构看,该插件按职责拆成三个部分:

  • src/index.ts—— Vite 插件入口,注册 Vite 的 transform 钩子,负责把 ESM 的静态/动态导入重写为经过模块缓存(module cache)的访问形式;
  • client/moduleCache.js—— 浏览器端运行时,实现把模块命名空间包装成可写Proxy的核心逻辑,并以<script type="module">注入测试页面;
  • dist/—— 编译产物(由tsc输出)。

在 Cypress 的 Vite 组件测试体系中,它常与@cypress/vite-dev-server搭配使用;本仓库 npm/vite-dev-server 即提供 Vite 作为组件测试 dev server 的桥接能力。这是一个面向测试的专用插件,README 明确建议只在运行 Cypress 测试时把它合入 Vite 配置。

快速开始:构建、检查与运行

从 package.json 可以看到该包的关键命令:

# 编译,输出到 dist/(tsc;即使有类型错误也会输出并提示) yarn build # 仅做 TypeScript 类型检查,不产出文件 yarn check-ts # ESLint 检查 yarn lint # 运行指定的组件测试 spec yarn cypress:run -- --spec <path-to-spec> # 交互式打开组件测试 yarn cypress:open

包本身声明了debugpicomatch(锁 2.3.0)两个运行时依赖;picomatch用于决定哪些模块需要被包装的 glob 模式匹配。Vite 未在 dependencies 中显式声明,而是作为使用者项目里的 peer 依赖被消费(AGENTS.md 明确说明它是“被 Vite 插件身份隐含的依赖”)。模块类型为"type": "module",入口指向dist/index.js,对外导出的类型位于dist/index.d.ts

集成方式:在 cypress.config 中按需合并插件

README 推荐只在 Cypress 测试运行时启用插件,一种做法是在cypress.config中通过 Vite 的mergeConfig把插件合入已有配置:

import { defineConfig } from 'cypress' import viteConfig from './vite.config' import { mergeConfig } from 'vite' import { CypressEsm } from '@cypress/vite-plugin-cypress-esm' export default defineConfig({ component: { devServer: { bundler: 'vite', framework: 'react', viteConfig: () => { return mergeConfig( viteConfig, { plugins: [ CypressEsm(), ], }, ) }, }, }, })

本仓库自测用配置 cypress.config.ts 是同样的思路,且在react()插件之后启用CypressEsm(...),并配置了ignoreModuleList: ['**/ignoreModuleList.cy.ts', '*MyAsync*']ignoreImportList: ['**/ImmutableModuleB*', '**/react-dom/client'](后者注释说明:React 18+ 下使用cypress/react需要跳过对react-dom/client库的转换)。

底层原理:Vite transform 阶段如何重写导入

插件入口导出的工厂函数CypressEsm返回一个名为cypress:mocksenforce: 'post'的 Vite 插件(见 src/index.ts),核心逻辑集中在transform钩子与transformIndexHtml钩子中。转换前会依次跳过三类文件:命中ignoreModuleList的模块、命中正则的非 JS 资源(如.svg|png|jpe?g|gif|tiff|webp|json|md|txt|xml|x?html?|css|less|sass|scss等,因为动态 import 图片、数据资源无需也不应代理)、以及被ignoreImportList点名的导入目标。

静态导入的重写(mapImportsToCache)

对每个被处理的模块,插件把形如下面的语法:

import DefaultExport, { NamedExport, Other as Alias } from 'module'

改写为:

import * as cypress_module_1 from 'module'; const DefaultExport = __cypressModule('moduleId#module', cypress_module_1, isDebug); const { NamedExport, Other: Alias } = __cypressModule('moduleId#module', cypress_module_1, isDebug);

实现上,插件先用正则捕获import ... from '...'声明(该正则以行首或空格为前置约束,避免误伤Refresh.__hmr_import('')之类的调用),再把导入变量按“解构块”与“非解构逗号块”分别切分,处理默认导入、命名导入、import * as fooimport { foo as bar }(会转成const { foo: bar })等多种形态,最终把每条声明重写为import * as cypress_xxx_N ...+const ... = __cypressModule(...)的组合。模块标识符会把原 moduleId 中的非字母数字字符替换为下划线,用于生成唯一的变量名。

动态导入的重写(mapDynamicImportsToCache)

import(...)动态导入通过另一套正则被包上一层运行时包装,例如:

const m = import("./mod_1") const m = await import("lodash") import("./mod_2").then(mod => mod)

会被改写为:

const m = __cypressDynamicModule(import("./mod_1")) const m = await __cypressDynamicModule(import("lodash")) __cypressDynamicModule(import("./mod_2")).then(mod => mod)

__cypressDynamicModule返回一个 Promise,它等待原始 import 完成后,把得到的模块交给同一套“代理化”逻辑处理(见 client/moduleCache.js)。

index.html 与运行时注入(transformIndexHtml)

transformIndexHtml会对测试页面的 HTML 做同样的静态/动态导入映射,并以fs.readFileSync把 client/moduleCache.js 的源码整体作为<script type="module">内联标签注入页面(src/index.ts 中通过MODULE_CACHE_FILEPATH指向../client/moduleCache.js)。这样一来,重写后的__cypressModule/__cypressDynamicModule两个全局函数就有了浏览器端实现。

浏览器端运行时:用 Proxy 让密封命名空间可写

client/moduleCache.js 是整个方案的另一半,核心函数createProxyModule的要点如下:

  • 默认导出优先:代理基座选择module.default || module,以同时兼容import DefaultValue from 'module'的默认导入;若默认导出是函数,则生成一个可被new的包装函数,保证类与函数组件都能实例化。
  • 数组不代理:数组默认导出直接原样返回,因为无法对数组做 spy,也无需代理。
  • 重定义属性描述符:通过Object.getOwnPropertyDescriptors遍历属性,以writable: trueconfigurable: true重新defineProperty,实现“解封”;prototype等个别键被NO_REDEFINE_LIST跳过。对值为函数的成员,若判定为 class(通过toString()正则探测^class\s.+?\{.+?\})则用Reflect.construct支持new,否则用保留调用上下文(apply)的普通包装函数。
  • Proxy陷阱的协同get陷阱在发现调用栈包含Sandbox.spy(即 Sinon 正在创建 spy)时返回真实函数而非包装版本,使 spy 能透传真实实现;set陷阱把新值写回 target 并为新函数补建包装;defineProperty会忽略带isSinonProxy标记的写入(防止 Sinon 覆盖掉包装函数);deleteProperty一律返回true阻止删除——Sinon 清理时会尝试删除属性,会破坏已包装的函数。
  • 模块缓存去重cacheAndProxifyModule以原模块对象为 key 缓存代理结果;若代理化过程抛错,则回退到原模块并在控制台警告“将不支持 stub/spy”。

两个全局入口(moduleCache.js)同时接收_debug布尔参数,用于控制上述log输出的开关。

配置项:ignoreModuleList 与 ignoreImportList

源码中CypressEsmOptions(src/index.ts)定义了三个选项:

配置项类型语义
ignoreModuleListstring[](picomatch 模式)匹配的模块本身不做代理化处理(原样放行)。例如把react-router加入后,该模块内部的导出、及其内部对依赖的使用都无法被 stub
ignoreImportListstring[](picomatch 模式)匹配的导入目标在任意导入方中都不走自定义映射,直接使用未改写的模块
ignoreListstring[]已废弃,兼容遗留配置,任何条目会被并入ignoreModuleList

所有值若非数组或含非字符串元素会直接抛错(assertIsArrayOrUndefined)。底层通过picomatch编译 matcher;对导入路径匹配时还会先剥掉开头的./(见isImportOnIgnoreList对 picomatch#77 的规避)。

典型用法:

// 跳过整个模块(支持 glob) CypressEsm({ ignoreModuleList: ['react-router', 'react-router-dom'], }) CypressEsm({ ignoreModuleList: ['*react*'], }) // 跳过某一处具体导入 CypressEsm({ ignoreImportList: ['**/internal/problematic-file.js'], }) // 使用 @cypress/react 测试 React 18+ 时通常需要: CypressEsm({ ignoreImportList: ['**/react-dom/client'], })

README 提醒两处需要分辨的语义细节:其一,加入ignoreModuleList的模块若被其他文件导入,导入动作仍会被插件处理,只是这些导入方拿到的是未改写的原始版本;其二,React 这类第三方依赖与 Proxy 实现存在已知冲突,且你通常并不想 stub React 自身,因此建议把 React 放进ignoreModuleList。何时用ignoreImportList的三种典型诉求:验证未改写行为的测试、排除行为不受支持的依赖、以及避开下述自动提升(auto-hoisting)破坏的代码。

调试手段

插件内置了基于debug库的日志命名空间cypress:vite-plugin-cypress-esm

DEBUG=cypress:vite-plugin-cypress-esm yarn cypress:run -- --spec <path-to-spec>

按 README 与 AGENTS.md 的说明,开启后你会在终端看到服务端代码转换日志(例如Remapping imports for module ...Mapping import N (...) in module ...、跳过资产/忽略列表的⏭️/🎨提示),在浏览器控制台看到模块拦截与Proxy包装日志(🔨 creating proxy module for ...✅ created proxy module for ...🕵️ Detected ... being defined as a Sinon spy等)。重写逻辑会把debug.enabled状态随调用一并传入客户端运行时,从而控制浏览器端日志输出。

测试与能力验证

仓库自带的组件测试是理解插件行为边界的最佳样本(cypress/component),涵盖:

  • stub.cy.tsx—— 对命名空间导入的模块做 stub:先用cy.stub(M, 'add')把加法改成乘法,断言行为被替换,且下一个测试用例又回到真实实现;也验证了 React 类组件、函数组件被整体 stub(替换成<h1>Stub Component</h1>),以及从node_modules静态导入的 lodash 方法可被 stub;
  • spy.cy.ts—— 验证cy.spy场景下调用栈检测路径能拿到真实函数实现;
  • dynamicImport.cy.ts—— 验证import('./mod_1')await import('lodash')等动态导入场景;
  • ignoreModuleList.cy.ts/ignoreImportList.cy.ts—— 分别验证模块级、导入级豁免配置的效果;
  • importSyntax.cy.ts—— 覆盖默认导出、命名导出、别名、import * as等各类 import 语法(对应 fixtures 目录里的defaultExportArray.tsnamedExportArray.tsexportDefaultConst.tsxkitchenSink.tsclass.ts等);
  • assetTypes.cy.tsedgeCases.cy.tsxreactQuery.cy.tsx—— 覆盖资源类型导入、边界用例与真实第三方库(TanStack React Query)。

已知问题与边界

README 列出了四类关键限制,使用时需格外注意:

  1. 自动提升(Auto-hoisting)缺失:ESM 规范会把 import 提升到模块顶部,而本插件不做任何 hoisting,只是把 import 就地转为变量引用。若代码在 import 声明前引用被导入值则会报错,典型如 Svelte 项目中的 HMR 逻辑,通常表现为 “use before define” 错误。可考虑用ignoreImportList绕过。

  2. 正则匹配的局限:当前转换基于正则而非 AST(README 表示未来会探索更稳健的 AST 方案)。它无法区分真实代码与字符串内的示例代码片段,可能误改字符串常量。

  3. 模块内自引用(self-reference)不生效:插件只拦截“进入模块”的外部调用,模块内部对自身函数的直接调用/引用比较不会被代理。例如mod_1.jsbar(mod) { return mod === foo },外部经Proxy传入的是包装后的foo,而内部mod_1foo是原始未包装函数,比较结果为false。React Router 懒加载路由等场景可能因此出问题,可用ignoreModuleList绕开。

  4. Sinon 兼容范围:插件只面向 Cypress 内部使用的 Sinon(cy.stub/cy.spy)设计,使用其他 mock 库或直接改写模块均不属于支持范围,通常无法按预期工作。

此外 import 语法虽基本全覆盖(README 称“所有已知 import 语法均已支持”),仍可能存在未发现的边角情况。

排障建议

README 给出的 Alpha 阶段排障流程:先确保插件与 Cypress 均为最新版;再临时把插件从测试用的 Vite 配置中移除,若问题依旧则与本插件无关;接着核对是否命中上述已知问题;然后用ignoreModuleList/ignoreImportList收缩范围,判断是否与某个具体模块/依赖相关;仍无法定位时,带上终端与浏览器 devtools 的 Debug 日志提交 bug 报告,附上可复现的最小工程最有助于定位。

兼容性与状态说明

按 package.json 与 README,该包遵循 MIT 许可,对外发布名为@cypress/vite-plugin-cypress-esm;README 给出的版本兼容矩阵为>= v1对应 Cypress>= v12>= v2(仅 ESM 模块版)对应 Cypress>= v16。它是一个pre-release alpha,官方提示存在 bug 与边界场景属于预期,报告问题请遵循上述排障流程。集成到真实项目时,建议仅在 Cypress 组件测试的 Vite 配置中启用,并始终为已知不兼容的第三方依赖(尤其是 React 与react-dom/client)保留豁免配置。

【免费下载链接】cypressFast, easy and reliable testing for anything that runs in a browser.项目地址: https://gitcode.com/GitHub_Trending/cy/cypress

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 23:34:50

Ollama本地部署大模型实战:从安装到API集成的完整指南

说个真实感受&#xff1a;本地跑大模型这件事&#xff0c;Ollama 基本是把门槛砍到了地表以下。以前你想在本地部署一个大模型&#xff0c;要么去编译 llama.cpp&#xff0c;要么对着 vLLM 的文档啃半天&#xff0c;环境搭完还不一定能跑通&#xff0c;光是 CUDA、编译工具链、…

作者头像 李华
网站建设 2026/9/8 23:33:34

飞控入门学习路径:从姿态解算到自定义模式实战

简介&#xff1a;面向飞控初学者的系统化学习资料包&#xff0c;围绕飞行控制系统的核心环节展开&#xff0c;涵盖单片机基础、GPS定位原理、传感器数据处理与飞控算法入门&#xff0c;并配套模块资料和视频讲解&#xff0c;帮助读者从硬件搭建到代码调试逐步建立完整知识框架。…

作者头像 李华
网站建设 2026/9/8 23:33:29

OpenAI新推理技术引发安全警报,AI Agent与内容生产迎来新变局

每周刷AI资讯的状态&#xff0c;基本上就是&#xff1a;热点一天一个&#xff0c;群聊永远在争论&#xff0c;真正值得停下来看两遍的没几条。衍辉AI速递9.3这期选了十条我觉得有点分量的消息&#xff0c;头条不是那些发布会通稿&#xff0c;而是OpenAI新推理技术引发的安全警报…

作者头像 李华
网站建设 2026/9/8 23:31:16

嵌入式黑盒协议逆向实战:UART波性分析、光耦反相与单片机插桩解密

1. 什么样的项目会让你走到“黑盒逆向”这一步 1.1 三种常见的现实场景 我最早接触嵌入式黑盒协议逆向&#xff0c;不是出于什么研究兴趣&#xff0c;而是被一个很实际的问题逼的&#xff1a;手里有一块老设备的控制主板&#xff0c;设备还在正常运转&#xff0c;但厂家停产了…

作者头像 李华