测试代码这东西平时不给它做优化等到项目一大了它自己就会跑出来找你算账。尤其是端到端E2E测试用 Cypress 或 Playwright 写出来的一大堆用例里面充斥着辅助函数、动态选择器、接口 Mock 数据、各种 Dev 态逻辑。这些代码虽然不至于让产品功能变慢但会让整个测试编译、加载、执行的时间慢慢变长CI 跑完一轮从十分钟变二十分钟从二十分钟变四十分钟谁也顶不住。我刚接手某电商模拟项目的时候那套 Playwright 用例编译产物已经堆到了 1.8MBCypress 那边还有个 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 能做的就是把“解析慢”和“无效执行”这两个痛点从源头上掐掉。它在压缩阶段会把代码变成体积更小、结构更紧凑的形式同时借助 DCEDead 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[nameusername], password: input[namepassword], }; 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 mapPlaywright 的报告也一样。少了它压缩后的报错信息只能看到压缩文件里的行列号对排查极其不利。我的建议是用在 CI 跑测试的产物必须带上.map文件本地开发调试用未压缩源码。由于 Terser 压缩非常快这两者可以同时维护互不影响。8.5 什么时候该放弃压缩测试代码说了这么多也有不推荐做压缩的场景。如果你的 E2E 测试只有几十条用例单个 spec 不超过 100KB压缩带来的收益可能只有 1-2 秒但对代码审查和报错排查造成的负担却很明显。在这种规模下更值得投入的是把测试代码结构理清、把冗余 helper 抽走而不是依赖压缩器做“魔法优化”。压缩永远只是手段不是目的。它适合的是那些“测试代码本身已经重到影响了运行效率”的项目而不是用来掩盖架构混乱的补丁。先把测试工程里的重复逻辑清理干净再让 Terser 上场才能看到最大收益。9. 我在实践中的体会折腾了一段时间 Terser我最深的感受是E2E 测试优化里最容易被忽略的环节恰恰是测试代码自己。大家都盯着被测应用的性能却忘了测试用例本身也是个需要维护和精简的软件产品。压缩代码并使用模块化策略是我个人测试运维工作中性价比极高的部分。如果你也想快速验证效果建议先拿 CI 耗时最高的一个 spec 文件做试验。把它单独拿出来压缩对比压缩前后的体积和执行时间再决定要不要全量铺开。这个过程不用写很多代码跑一次就能看到收益也方便说服团队投入时间做长期治理。最后再分享一个小技巧把 Terser 的配置文件和测试代码的 lint 规则放在一起维护每次提交压缩配置变更时自动跑一次全量测试。哪怕只是改了一个压缩选项也可能影响所有用例。有这层自动兜底优化的时候才会底气更足。