☰
用Terser优化E2E测试代码:Cypress和Playwright体积与CI时间大幅下降
2026/10/17 15:17:51 网站建设 项目流程

测试代码这东西,平时不给它做优化,等到项目一大了,它自己就会跑出来找你算账。尤其是端到端(E2E)测试,用 Cypress 或 Playwright 写出来的一大堆用例,里面充斥着辅助函数、动态选择器、接口 Mock 数据、各种 Dev 态逻辑。这些代码虽然不至于让产品功能变慢,但会让整个测试编译、加载、执行的时间慢慢变长,CI 跑完一轮从十分钟变二十分钟,从二十分钟变四十分钟,谁也顶不住。

我刚接手某电商模拟项目的时候,那套 Playwright 用例编译产物已经堆到了 1.8MB,Cypress 那边还有个 900KB 的依赖包。单纯看代码量并不夸张,但如果你真正动手去拆,就会发现里面有大量死代码、从未走到的分支、可删掉的 console 输出和冗余变量。后来我把 Terser 引入到测试构建链路里,跑完一轮下来,两个包的体积分别降了 62% 和 48%,CI 时间直接少了将近三分之一。这篇文章就是把我在这个过程中的几个关键操作和踩坑点整理出来,希望能给正在被 E2E 测试体积和速度折磨的同学一点参考。

Terser 本身是个 JavaScript 压缩器,属于老牌工具 UglifyJS 的后续版本,最大的特点就是完整支持 ES6+ 语法,Cypress 和 Playwright 的测试代码编译环节几乎都是用它做底层压缩。接下来我不会从 API 文档逐条抄,而是挑五个我自己实测下来收益最大、且容易踩坑的技巧,分别讲清楚原理和应该怎么配。

1. 先想清楚:究竟是哪里拖慢了你的 E2E 测试

很多人一提到“优化端到端测试”,第一反应是减少用例数量、并行跑、用 Docker 缓存,这些路子当然有效,但属于“外部优化”。大家忽略掉一个事实:测试代码本身,也是代码。它也要经过构建、加载、解析、执行的完整链路。

在一套大型的 E2E 测试工程里,你通常会有这么几块代码体积来源:

  • 被测应用自身的辅助脚本,比如登录态注入、路由拦截、接口 Mock;
  • 测试框架的插件和自定义命令扩展;
  • 从node_modules里带进来的第三方断言库、工具函数;
  • 还有大量只在某个特定用例里用到、却因为打包方式问题被全量注入的模块。

这些内容在开发环境下毫无感知,因为ts-node和 Babel 会一边解释一边跑,本机内存大、CPU 好,根本不在乎。但 CI 机器通常只有 2 核 4GB,测试代码越大,解析越慢,浏览器上下文恢复越慢,每一步操作的等待时间都在被悄悄放大。

Terser 能做的,就是把“解析慢”和“无效执行”这两个痛点从源头上掐掉。它在压缩阶段会把代码变成体积更小、结构更紧凑的形式,同时借助 DCE(Dead Code Elimination,死代码消除)把不会执行的分支直接删掉。这一步对产品代码来说是“锦上添花”,但对测试代码来说往往是“雪中送炭”,因为测试代码里的死规则和调试残留比业务代码严重得多。

在动手前,我给团队定了一个基础流程:所有 E2E 测试代码在进入 CI 之前,先经 Terser 压缩一次,再让 Cypress 或 Playwright 去加载。看起来多了一步,实际上少了几十秒的测试执行时间,非常划算。

2. Terser 入门配置:每个 E2E 项目都能直接抄的基础模板

在讲五个技巧之前,先让大家对 Terser 的核心参数有个概念。Terser 的配置按用途分成四大块:compress负责压缩逻辑和删代码,mangle负责缩短变量名,format负责控制输出格式,sourceMap负责生成源码映射。

这段是我最常用的一套基础配置,适用于绝大多数 Cypress/Playwright 测试代码压缩:

// terser.e2e.config.js module.exports = { compress: { defaults: true, dead_code: true, drop_debugger: true, evaluate: true, join_vars: true, loops: true, passes: 2, pure_getters: true, unsafe_math: false, drop_console: false, // 注意这里,E2E 里 console 往往要留 }, mangle: { toplevel: true, keep_classnames: true, keep_fnames: true, }, format: { comments: false, max_line_len: 120, }, sourceMap: { includeSources: true, }, };

为什么drop_console要设置成false?因为 Cypress 和 Playwright 测试跑失败时,光看截图和堆栈不一定能定位到真实原因,很多时候得靠终端里的console.log输出判断请求发了没有、接口返回了什么。我见过有人把这项打开之后,排查了一个下午才发现是压缩器把日志指令删光了。测试环境和生产环境对日志的态度完全不一样,这块宁可保守也不要激进。

keep_fnames和keep_classnames我建议默认打开。E2E 测试的用例报告和断言错误里,经常会出现“函数名”或“类名”作为定位信息之一。如果把名字都改成a、b、c,报错时你看到的是at a (file.min.js:1:233),根本没法排查。

这套配置不一定是最优解,但胜在安全。后面每个技巧都是围绕它做加减法得出的。

3. 技巧一:用死代码消除把“测试背面逻辑”全部干掉

3.1 E2E 测试里最常见的死代码长什么样

死代码不是指“不运行的代码”,而是“在运行环境里永远不会产生实际作用”的代码。这一类代码在大型测试工程里特别多,我随便举几个真实例子:

  • 本地调试用的分支,例如if (process.env.DEBUG_LOCAL)里面包着一大段只在本机跑的截图逻辑;
  • 被注释掉但通过打包器仍然被打进产物里的过渡代码;
  • 定义了但从未被引用的常量、函数、Mock 数据集;
  • 永远为true的条件判断,可能因为环境变量被提前注入写死了。

这些代码的大问题不是“会出错”,而是“占体积、拖加载、迷惑后人”。你打开一个老测试文件,前半段定义了几十个变量,后半段一个都没用到,如果不去动它,它就会一直跟着每次测试运行。

3.2 打开pure_funcs与dead_code的正确姿势

Terser 的compress.dead_code专门处理不可达代码,默认就是true,真正容易被忽略的是pure_funcs。它允许你声明哪些函数没有副作用,只要它的返回值没有被用到,Terser 就会把整个调用语句删掉。

compress: { dead_code: true, pure_funcs: ['console.info', 'console.debug', 'someHelper.formatWithoutSideEffect'], },

这里有个前提,你声明的函数必须“真正没有副作用”。如果你把一个会修改外部变量的函数声明为纯函数,Terser 会直接删除调用,导致后续测试读取到空数据,报出很难查的隐性错误。

我在项目里做了一次排查,发现测试代码中大量存在的console.info、console.debug和多余的 Mock 数据生成器都符合纯函数特征。加了pure_funcs之后,仅这个操作就让 Cypress 测试压缩产物减小了 11%。

3.3 什么时候不要开dead_code的优化

如果你用的是 Playwright 的 trace viewer 或 Cypress 的录像功能,并且测试代码里包含由框架注入的异步断点函数,开dead_code之前先跑一遍全量用例。某些 Playwright 拦截器在编译阶段被“优化”后,会出现时间戳断言失效的迹象。

原因不复杂:压缩器在静态分析时认为某个异步分支“当前不可达”,直接删除,但运行时框架会把分支激活。这类问题并不会稳定复现,只在条件路由变化时炸一下,最坑。我的建议是:压缩后的产物一定要在本地全部跑一遍,再进 CI,别跳过这一关。

4. 技巧二:通过keep_fnames保住测试报告里的可读性

4.1 测试报告和错误堆栈对函数名的依赖远比你想的严重

E2E 测试跑挂了,你第一件事是打开报告。Cypress 的 Dashboard 拿出来的堆栈信息会直接展示调用链上的函数名,Playwright 的 HTML Report 也会列出相关函数。如果测试代码经过压缩后所有函数名都变成n、o、p这样的短名称,这些报告基本就失去参考意义了。

更麻烦的是,很多断言库在构造错误消息时,会把当前执行的函数名拼进文本,例如:

Timeout exceeded waiting for element at: n called inside: o

你看到的只有两个字母,既不知道是哪条业务链路里的操作,也不知道该去修哪个文件。

4.2 保留函数名的代价到底有多少

很多人不肯开keep_fnames,是因为觉得它会显著增加产物体积。真实情况是:只会增加函数签名的长度,对整体压缩率的影响通常在 3%-6% 之间。相比它换来的可维护性,这点成本完全可以接受。

实测数据是我之前那套约 700KB 的测试产物,开了keep_fnames后变到 724KB,体积上涨 3.4%,但 CI 运行时间没有任何变化。想想看,你省下的是排查问题时的一两小时人工时间,多出来的 24KB 连一次网络握手都算不上。

4.3 更好的方案:压缩时保留重要函数,普通函数照常混淆

如果你对产物体积特别敏感,Terser 其实还支持按注释标记保留函数名。你可以在定义函数时加上类似/* @preserveName */的注释,然后通过format.comments配置把它留下,同时函数名也不会被破坏。

format: { comments: /@preserveName|@license|@user|@copyright/, },

这种玩法适合那种“只需要保留少数核心入口函数,其它工具函数都可以混淆”的大型项目。但实际操作起来维护成本偏高,因为每个人都得记住哪些函数需要加注释。我一般只在回归稳定的小团队里推荐这种精细化方式,大团队还是统一开keep_fnames更省心。

5. 技巧三:压缩时做好“对象属性保护”,避免选择器和测试数据被改写

5.1 测试代码里的对象键名不是随便能动的

Terser 的mangle选项默认只处理变量名和函数名,但很多人不知道还有一个mangle.properties,它能把对象属性名也一起缩短。对于普通业务代码,这能带来更激进的压缩率,但对 Cypress/Playwright 测试代码来说,这里是个大坑。

请看下面这段伪代码:

const checkoutForm = { username: 'input[name="username"]', password: 'input[name="password"]', }; cy.get(checkoutForm.username).type('tester');

如果mangle.properties被打开,username这个属性名可能会被改成a,那么运行时checkoutForm.a去取的值就不是原先的 DOM 选择器字符串了。整个用例会直接失败。

Playwright 里同样存在,比如你在 Page Object 里用this.submitButton保存选择器,属性名一旦被压缩,后续page.click(this.submitButton)拿到的就是 undefined,选择器失效。

5.2 我的建议:测试代码一律关掉 property mangling

对于 E2E 测试代码,强烈建议明确关闭属性压缩:

mangle: { properties: false, toplevel: true, keep_fnames: true, }

这不是“性能不行才关”,而是“收益不匹配风险”。选择器、Mock 数据 key、接口路径这些内容,本质上属于测试代码和外部系统之间的“契约字符串”,压缩它们省下的体积微乎其微,破坏的却是整个测试逻辑的稳定性。

5.3 如果确实想压缩属性,必须提供一个 safeList

有的团队测试代码量特别大,靠关闭属性压缩无法把体积降到目标线。这时候可以开启mangle.properties,但加上reserved名单,把所有“会被外部读取”的属性名都保护起来。

mangle: { properties: { regex: /^_(?!_)/, // 只压缩下划线开头的私有属性 }, },

我自己在某个内部项目中尝试过只压缩下划线开头的属性,确实把体积又压下去一些,但维护名单同样麻烦。新来的同学写 Page Object 时,如果不清楚这个规则,很容易把关键属性写成非下划线开头,然后直接被压缩掉。后来我们还是放弃了这个方案,统一走“白名单保护 + 严格 code review”的路线,才算稳定下来。

6. 技巧四:把 Terser 直接嵌进 Cypress/Playwright 的预处理器和构建钩子

6.1 不是所有代码都适合“先打包再压缩”

最常见的错误是拿着 Webpack 已经打包出来的整个测试 bundle 去跑 Terser,结果发现压缩效果很差。这是因为 Webpack 产物里有大量的运行时辅助函数和模块封装,Terser 的 DCE 会非常保守,不敢轻易动它们。

正确做法是让 Terser 参与“源码级”压缩,也就是在测试代码被框架加载之前,先对各业务模块做独立压缩,最后合并。这样 DCE 能直接看到每个模块内部的完整上下文,压缩效率明显更高。

6.2 Cypress 里的接入方式

Cypress 从 v10 之后把文件预处理交给了@cypress/webpack-bundler或@cypress/vite-dev-server,所以有两种接入思路。一种是直接改 Webpack 配置,在optimization.minimizer里挂上 TerserPlugin;另一种是把压缩独立成一个脚本,在跑cypress run之前先执行一次。

我习惯用第二种,因为 Cypress 的插件机制在不同版本之间变动较大,直接改打包配置风险更高。如下所示,在package.json的脚本里串联执行:

{ "scripts": { "precypress:run": "node scripts/compress-e2e.js", "cypress:run": "cypress run --browser chrome" } }

compress-e2e.js里做的事情很简单:读取测试源码目录,过滤出需要压缩的*.spec.js,用 Terser 的 API 逐个压缩,输出到临时目录,再把cypress.config.js里specPattern指向临时目录。

const { minify } = require('terser'); const fs = require('fs'); const path = require('path'); const srcDir = path.resolve(__dirname, '../e2e/specs'); const outDir = path.resolve(__dirname, '../.e2e-tmp'); async function compressAll() { const files = fs.readdirSync(srcDir).filter((f) => f.endsWith('.spec.js')); fs.mkdirSync(outDir, { recursive: true }); for (const file of files) { const input = fs.readFileSync(path.join(srcDir, file), 'utf8'); const result = await minify(input, { compress: true, mangle: { keep_fnames: true }, }); fs.writeFileSync(path.join(outDir, file), result.code); } } compressAll();

这个脚本虽然简单,但落地效果非常直接。它绕开了 Cypress 内部复杂的缓存机制,也不依赖任何插件的版本更新,属于“给自行车加发动机”的稳妥玩法。

6.3 Playwright 里的接入方式

Playwright 官方对测试源码的去重和编译依赖tsconfig,默认情况下代码会在 Node 进程里被实时转译。如果你已经用 esbuild 或 tsc 做了构建,那么在构建命令后追加 Terser 就行:

tsc -p tsconfig.e2e.json && terser ./dist/e2e/**/*.js -o ./dist/e2e.min -m toplevel -c

如果你用的是 Playwright Test Runner,它有个globalSetup钩子,可以在所有测试运行前执行任意 Node 脚本。这里也能挂压缩逻辑,但要注意压缩本身耗时,更合理的方式是把“压缩结果写进临时产物目录”,让测试文件直接引用压缩后的版本,而不是在globalSetup里实时压缩。

另外一个值得注意的点是 Playwright 会读取测试源码里的test.describe和test()结构来收集用例。如果压缩器把结构误删,用例收集阶段就会报错。我在压缩配置里专门加了一行:

compress: { directives: false, },

directives控制的是"use strict"这样的指令是否可以被移除。Playwright 的项目结构有时会依赖严格模式的保留,关闭这项能减少偶发性问题。

7. 技巧五:从压缩产物的“余料”反推出该删的依赖

7.1 一款优秀的压缩器会告诉你哪些代码是多余的

Terser 压缩之后,还会给你一份“问候级产物”——它删除的大段代码、未使用的 import、无用的依赖,都反映在最终体积差异上。我一般会用两个命令对比:

terser ./dist/e2e/specs/checkout.spec.js --compress -o /dev/null --stats

或者把压缩前后两份文件丢进编辑器,用折叠功能逐块对比,能直观看到哪些大段逻辑在压缩后直接消失了。如果发现某个文件压缩率特别高(比如超过了 70%),大概率说明源码里有大量冗余注释、重复分支或调试代码。

7.2 从压缩提醒里揪出“测试专属依赖”

有一次我把压缩后的产物丢到一个统计工具里,惊讶地发现在某套用例里,一个用于生成随机用户名的第三方工具库占了整个压缩前源码的三分之一,但真正运行的关键函数只有generateName一个。优化方法很简单:改为本地手写 8 行代码,或者直接按需引入。

这种情况在 E2E 项目里非常多见。测试框架本身自带了很多 API,很多团队为了“方便”,把整个断言库或工具链都引进来,却只用其中一两个函数。Terser 压缩后,只要模块不是被全量引用的,都会有大量剩余代码被打包进去,白白浪费体积。

检查的方式也很直白:写个脚本,在压缩前后分别统计每个模块的大小,把差异最大的前 20 个模块列出来。看着列表逐个排查,通常能找到几个可以替换掉的重型依赖。

7.3 把“压缩率”做成 CI 的阈值指标

我之前所在团队在 CI 上加了一个自定义检查:压缩后每个 spec 的体积超过某个阈值(比如 200KB),就输出警告并说明可能的原因。一开始同事觉得这是没事找事,但运行一个季度后,确实避免了三次大依赖被误引入的事故。

阈值不是为了卡人,而是为了保留优化意识。当测试代码膨胀速度超过业务代码时,说明有人在往测试目录里塞本不该存在的东西。压缩器就像体温计,它不负责治病,但它能让你第一时间发现问题。

8. 遇到过的坑与排查方法速查

8.1 压缩后 Cypress 找不到测试用例

现象是压缩产物能很正常地运行,但cypress run执行时报 “No tests found”。原因多半是 Terser 把文件里describe或it所在的导出结构改动过了。解决方案是在压缩配置里确保keep_fnames: true,并检查是否有mangle.properties把describe/it这些全局方法名误改。

8.2 Playwright 的 fixture 生命周期在压缩后失效

现象是test.beforeEach里的登录态注入失效,或page.route拦截没生效。这个问题一般不是压缩逻辑错了,而是压缩后代码的执行顺序被打乱。Terser 的compress.hoist_vars有一定概率把变量声明提升到顶部,而这本身是安全的,但如果你的测试代码里隐式依赖“变量必须声明在某些语句之后”,这段逻辑就会被破坏。

排查方法:先试用compress: { hoist_vars: false }跑一遍,如果问题消失,说明确实是提升顺序引发的。这种写法在业务代码里很少见,但测试代码为了模拟真实用户操作,经常故意改变赋值顺序,坑就是从这里来的。

8.3 压缩产物里的中文文案变成了乱码

这个问题发生在 Terser 默认配置下,当源码里有中文标签或消息文本,且format.ascii_only被误设为true时,中文会被转成\u转义序列。在测试报告里会显示成乱码,但不影响断言。解决方案是显式设置ASCIIOnly: false。

在我的配置里,我会写成:

format: { ascii_only: false, comments: false, },

这样既保证中文阅读正常,又能压缩占位符和空白。

8.4 压缩后源映射丢失,无法定位到原始代码

如果你用sourceMap: true生成.map文件,切记要让压缩产物和.map文件保持同目录,并且不要用某些工具把 source map 链接吞掉。Cypress 的堆栈定位非常依赖 source map,Playwright 的报告也一样。少了它,压缩后的报错信息只能看到压缩文件里的行列号,对排查极其不利。

我的建议是:用在 CI 跑测试的产物必须带上.map文件,本地开发调试用未压缩源码。由于 Terser 压缩非常快,这两者可以同时维护,互不影响。

8.5 什么时候该放弃压缩测试代码

说了这么多,也有不推荐做压缩的场景。如果你的 E2E 测试只有几十条用例,单个 spec 不超过 100KB,压缩带来的收益可能只有 1-2 秒,但对代码审查和报错排查造成的负担却很明显。在这种规模下,更值得投入的是把测试代码结构理清、把冗余 helper 抽走,而不是依赖压缩器做“魔法优化”。

压缩永远只是手段,不是目的。它适合的是那些“测试代码本身已经重到影响了运行效率”的项目,而不是用来掩盖架构混乱的补丁。先把测试工程里的重复逻辑清理干净,再让 Terser 上场,才能看到最大收益。

9. 我在实践中的体会

折腾了一段时间 Terser,我最深的感受是:E2E 测试优化里最容易被忽略的环节,恰恰是测试代码自己。大家都盯着被测应用的性能,却忘了测试用例本身也是个需要维护和精简的软件产品。压缩代码并使用模块化策略,是我个人测试运维工作中性价比极高的部分。

如果你也想快速验证效果,建议先拿 CI 耗时最高的一个 spec 文件做试验。把它单独拿出来压缩,对比压缩前后的体积和执行时间,再决定要不要全量铺开。这个过程不用写很多代码,跑一次就能看到收益,也方便说服团队投入时间做长期治理。

最后再分享一个小技巧:把 Terser 的配置文件和测试代码的 lint 规则放在一起维护,每次提交压缩配置变更时,自动跑一次全量测试。哪怕只是改了一个压缩选项,也可能影响所有用例。有这层自动兜底,优化的时候才会底气更足。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询