一提到单元测试执行时间很多人的第一反应是整个测试套件“卡”在十几分钟甚至半小时以上。我接到过一个前端老项目单测全量要跑23分钟开发机风扇直接拉满CI里排队排到怀疑人生。团队那时候的默契是“提交完代码先去冲杯咖啡回来看结果”说白了大家已经默认慢就是单测的一部分了。这篇文章专门讲怎么把单元测试执行时间压下来。我会从“哪些测试根本不值得跑”聊到“怎么让值得跑的测试跑得更快”中间穿插并行、Mock、Vue专项提速以及最近很热的基于LLM的单元测试辅助思路。适合正在被慢测试折磨的前端、后端和测试开发同学也适合刚接触单测、想从第一天就把“快”变成默认配置的新人。1. 先别急着优化先找出单元测试执行时间里的“时间黑洞”拿到一个慢的测试套件最忌讳的事情就是直接开并行、换机器、调 CI 配置。因为“慢”是一个结果不是原因。很多时候你真正常用的只有 5% 的用例剩下 95% 都在陪跑这时候上再多的并发核数也只是把钱烧在陪跑上。我每次接手这种项目第一步永远是先给测试建一份“时间账本”。方法不复杂用测试框架自带的能力把每个用例的耗时导出来按耗时从大到小排看看前20个用例占掉总时长的多少。1.1 用现成工具给每个用例计时后端用 pytest 的话一个参数就能搞定pytest --durations20 --durations-min0.5--durations20会列出最慢的 20 个用例--durations-min0.5能过滤掉小于 0.5 秒的用例避免输出被大量小用例刷屏。跑完直接看最下面那段汇总哪个文件哪条用例最慢一目了然。前端 Jest 可以这样做npx jest --silent --verbose --json --outputFilejest-result.json--json会把完整结果写到文件里里面每个用例都带duration字段。找个脚本解析一下按时间降序排序最慢的几十条立刻浮出水面。Vitest 也差不多npx vitest run --reporterjson --outputFilevitest-result.json我用这种方式做过一次摸底结论非常典型套件总共 23 分钟最慢的 18 个用例加起来就占了 14 分钟。那 18 个用例里有一大半是“没做任何事但每次都等 10 秒超时”的用例剩下的则是真的渲染了巨复杂组件树的重型用例。前者可以砍后者可以改这两个方向完全不同不摸底根本分不出来。1.2 分清算慢的类型别用同一套方案硬解我看到过有人把所有慢测试归因于“机器不行”或者“框架不行”然后盲目加机器效果很差。实际上单测变慢通常只有四类原因第一类是启动成本比如整个测试环境要初始化数据库、要加载大型配置文件、要等某个服务端口就绪这类慢是“一次性的”不管你跑 1 个用例还是 100 个用例都要付这笔成本。第二类是单个用例真的重比如每次都真实走一遍 HTTP、真实操作数据库、完整挂载一个几十层深的组件树。这类用例可能是合理的集成测试但混在单元测试里会把整体速度拖垮。第三类是资源排队多个用例或进程争抢同一个端口、同一个临时文件、同一个数据库表后面的用例只能等前面的释放资源。这类慢在单机跑的时候不明显一开并行立刻变成各种 timeout 和 flaky。第四类是断言层面的假等待代码里写了setTimeout(1000)或者轮询间隔太长本来 50 毫秒能等到结果硬是等了 500 毫秒甚至更多。这类问题最值得优先处理因为收益高、风险低。把这四类原因分清楚之后再决定是“砍用例”“改用例”还是“调执行方式”才不会白费功夫。2. 减法优先减少单元测试执行时间的第一步是砍掉多余用例绝大多数测试套件变慢都不是单个用例太慢而是用例数量膨胀到失控。项目开发了三年五载每个阶段都会加一批测试但很少有人回头清理。结果是同一段逻辑被三层测试覆盖框架自己的行为也被当成业务逻辑反复断言这些用例每一个都不算慢叠加起来就成了一场马拉松。所以在谈“怎么跑得更快”之前我强烈建议先做一轮减法。减少单元测试执行时间最有效的方法就是让套件里只剩下必要用例。2.1 三层筛查法判断一个测试该不该留我自己有一套很朴素的筛查逻辑每看到一个用例就问三个问题。第一这个用例断言的是我们的业务代码还是框架/浏览器/第三方库本身的行为比如 Vue 的响应式更新、路由跳转、数组方法这类能力框架自己的维护者已经用成千上万个用例兜底了你在自己项目里再断言一遍ref值变了基本属于把别人的饭再吃一遍。这类用例可以直接删。第二同一个分支路径是否已经有更高层级的测试覆盖组件测试和接口测试都断言了“提交按钮点击后表单重置成功”那组件测试里留着交互跳转就行接口层只关心请求体和响应处理。重复覆盖不会让质量翻倍只会让执行时间翻倍。第三这个用例如果挂了会不会真的影响我们对发布风险的判断如果答案是“不会”或者“我也说不上来”那这个用例大概率是为了凑覆盖率写的删掉没有任何心理负担。这层筛查需要勇气但做完之后你会发现套件缩水 30% 到 40% 非常正常而且完全不影响可靠性。2.2 给用例分级冒烟、核心、全量各跑各的时机删完冗余用例之后剩下的用例也不应该永远一视同仁地全量跑。我建议在文件命名或目录层面把用例分成两类一类是快速冒烟用例一类是全量回归用例。比如前端可以在文件名上做区分xxx.spec.ts作为全量回归用例xxx.smoke.spec.ts作为提交前必须快速跑完的冒烟用例。配置里用testMatch或者 Vitest 的--include分别框定两套执行范围。本地开发阶段配合--watch或 Jest 的--changedSince只跑当前改动相关用例全量回归留给 CI 和发布前。我自己团队的实际做法是提交前 3 分钟内跑完冒烟加改动影响域合并主干前跑一次全量回归发布前再跑一次带数据库和真实依赖的完整验证。每一层的测试成本和它要回答的问题匹配速度自然就上来了。2.3 用 LLM 辅助做冗余检测让减法更彻底最近“基于LLM的单元测试”被聊得很多很多人想着让大模型帮忙生成测试这个方向确实有用但我更建议先让它帮忙“审计”存量测试。生成新测试会加大测试量审计则是帮我们减少测试量这两件事对执行时间的影响完全相反。实际操作中可以把测试文件的用例标题、断言片段、对应的覆盖率摘要整理成结构化文本请模型按“断言目标”分类归并。比如模型能识别出handleLogin,login 函数,AuthService.login这三处测试都是在验证登录成功路径再结合覆盖率报告发现它们跑的是同一条分支那就是典型冗余。要注意的是LLM 的输出只能作为线索不能直接作为删除依据。我一般会让它给出“疑似重复”清单然后人工对照源码确认一次确认后再删。这么一轮下来等于多了一双能快速扫读全库的眼睛尤其适合那种几千个测试文件的大仓库。3. 并行化让单元测试执行时间真正跟 CPU 核数挂钩砍完冗余用例之后如果执行时间还是不够快下一步才是上并行。并行这件事的效果很直接但坑也很多。很多人一开并发就迎来一波 flaky最后又默默退回单线程其实是没搞明白并行需要什么样的工程前提。3.1 前端 Vitest/Jest 的并行参数与常见配置Jest 默认会按 CPU 核数留一半给其他进程--maxWorkers可以手动控制。我一般这样用npx jest --maxWorkers4本地开发机器调小一点避免整台电脑卡死CI 机器可以给满比如--maxWorkers100%。如果发现某个用例就是不稳定可以先--runInBand跑一遍确认是不是并发导致的这个参数会强制单进程串行执行非常适合定位 flaky。Vitest 默认就跑多线程我通常会根据项目情况用配置把执行方式固定下来import { defineConfig } from vitest/config export default defineConfig({ test: { pool: forks, poolOptions: { forks: { maxForks: 4, minForks: 1, }, }, sequence: { shuffle: true, }, }, })pool: forks用的是子进程而不是 worker 线程对于带原生依赖或大量 DOM 操作的测试更稳。sequence.shuffle会把用例顺序打乱这样能尽早暴露“依赖执行顺序”这种隐性 bug很多 flaky 问题就是靠这个配置揪出来的。3.2 后端 pytest 的并行写法Python 侧最常用的是pytest-xdistpip install pytest-xdist pytest -n auto --durations10-n auto会根据 CPU 数量和配置自动决定进程数。要注意不是所有用例都适合并行比如共用同一个数据库文件的用例多个进程同时写很容易互相踩。所以我通常会在并行前先做一轮资源隔离把每个进程的数据环境彻底分开。3.3 并行不是白拿的资源隔离三板斧并行之所以容易翻车本质上是多个进程共享了不该共享的资源。我总结了三个最常出问题的位置和对应的处理方式。第一是端口。凡是启动本地服务或 mock server 的测试都不要写死端口要用0让系统随机分配再把实际端口暴露出来供测试读取。写死8080的测试在并行情况下几乎必挂。第二是数据库。并行时每个 worker 最好有独立的 schema 或独立的连接串测试结束直接清空。如果做不到至少做到用例内部自建自清绝不能在session级别共享数据。第三是文件系统。临时目录、日志文件、配置文件这一类都要按进程或用例隔离别把所有 worker 引导到同一个临时文件上。这个问题的典型症状是“单独跑全过并行跑偶尔挂”非常迷惑人。只要把这三类资源隔离好并行基本上就能拿到接近线性的收益而不是今天的 flaky 增多、明天半夜排查。4. 微观提速把单个用例从秒级压到百毫秒级减完、并行完之后最后一道工序是打磨单个用例的执行效率。一套测试如果有几千个用例每个用例少 200 毫秒总时长就少好几分钟。这部分优化不怎么性感但积少成多效果非常扎实。4.1 消灭固定等待用可等待的断言替代 sleep我见过大量睡眠式等待典型的写法是这样的await new Promise((resolve) setTimeout(resolve, 1000)) expect(list.length).toBe(3)这种写法有两个毛病一是机器快的时候白白浪费 1000 毫秒二是机器慢的时候 1000 毫秒可能根本不够造成偶发失败。正确做法是让测试“主动等到条件满足”比如 Vitest 的vi.waitForawait vi.waitFor(() { expect(list.length).toBe(3) }, { timeout: 1000 })条件满足就立刻返回不满足才等到超时。这样平均耗时取决于真实响应速度而不是拍脑袋写死的等待时长。对于时间相关的逻辑更是如此。如果一个用例要模拟“三分钟后过期”这种场景不要真的等 180 秒用假定时器直接拨时间vi.useFakeTimers() // ... 触发逻辑 vi.advanceTimersByTime(180000) // ... 断言 vi.useRealTimers()我把项目里所有裸setTimeout型等待清掉之后整套测试的运行时间直接砍掉了将近一半而且稳定性还提升了因为原来那些“有时候不够等”的用例再也不随负载漂移了。4.2 数据准备从“真库”切换到“替身”很多用例慢是因为每个用例都在准备一套“完整”的数据环境初始化 ORM、连真实数据库、插入多条关联记录跑完再清库。这套链路在单测阶段完全没必要。单元测试追求的是“验证这段代码在给定输入下输出是否正确”不是“验证数据库的持久化能力”。所以数据准备优先考虑两点一是能内存跑就内存跑比如 SQLite 的:memory:模式比连独立数据库快一个数量级二是尽量用工厂函数精确定制数据而不是每次构造一个全量的真实对象。举一个我常用的模式。后端一个用例需要“用户已登录”“购物车有三件商品”“其中一件已下架”比起真的建一个生产者消费者完整链路我更愿意让工厂函数只产出这三件东西的最小字段集。少做的每一件多余的事最终都会体现在执行时间上。前端组件的测试数据也是同理能用静态对象就用静态对象不要每次都走一遍接口请求再 mock 返回。请求环节能省就省把关注点放在组件自身的行为上。4.3 挂载组件时收着点学会用 mount 的“轻量版”前端单测变慢的头号大户是“太重”的组件挂载。一个页面组件往往带着路由、Pinia、几十个子组件、第三方 UI 库你用mount全量渲染等于在测试里跑了一个 mini 应用不慢才怪。能拆的拆不能拆的用shallowMount。shallowMount只渲染当前组件子组件统一替换成占位这样我们的测试只关注被测组件自身的逻辑和交互不会被子树的渲染拖累。等真正需要验证子组件配合的时候再单独写集成型测试。另外给不需要真实行为的第三方依赖补 stub。比如把RouterLink换成普通a标签把弹窗库、图表库这类重型组件替换成空占位不需要额外引入它们的完整渲染链路。我不建议把这套 stub 逻辑写在每个用例里统一放在 setup 文件中这样整个测试文件都会自动变轻而且不用每个 mesh 都写一遍。5. Vue 单元测试提速与报错排查实录说完了通用手段我来重点聊聊 Vue 项目。我自己大部分单测提速实战都发生在 Vue 3 Vitest 的栈上而且每次一提到 vue单元测试报错周围人总能共鸣出一堆熟悉的面孔。这些坑表面上是“报错”实际上它们也是测试变慢的元凶。5.1 jsdom 还是 happy-dom环境选型的真实差距Vue 单测默认跑在 DOM 环境里Vitest 常见选择是jsdom或happy-dom。很多人一开始默认选 jsdom觉得生态稳但其实 happy-dom 在启动和整体执行上明显更快因为它实现得更“轻”。对于一个上千用例的项目环境本身的差距可能就有几十秒。不过 happy-dom 也不是白拿的它有些 API 实现得不够完整比如某些布局 API 或者事件行为跟真实浏览器有差异。我的建议是纯逻辑组件和 composables 直接跑node环境不需要 DOM涉及真实交互的组件测试优先试 happy-dom真的踩到 API 缺口再退回 jsdom或者在 setup 文件里补个 polyfill。别一上来就全项目锁死 jsdom等于年年背着高价房租。5.2 配好 Vitest绕开“慢 报错”的组合拳Vue 项目的 Vitest 配置里有几个选项对执行时间影响特别大。第一个是把 CSS 处理关掉。组件里引的.css、.scss文件单测里根本不需要真的解析编译反正断言的又不是样式。在配置里只要不开启 CSS 处理Vitest 遇到样式文件会直接跳过可以省掉一大块工作。第二个是合理处理依赖预打包。有些第三方库发布的是 ES 包Vitest 需要处理它们如果每次都现场转换耗时很夸张。把这类库加进server.deps.inline让 Vite 预打包一次后续所有用例复用速度会明显提升。第三个是控制每个测试文件的环境切换。如果一个项目里既有jsdom又有node环境的测试Vitest 在文件之间切换环境有额外成本。尽量把同环境的测试文件放一起别让环境在两个文件之间反复横跳。5.3 高频 Vue 报错其实是测试变慢的隐性原因Vue 单元测试里很多报错不是一开始就 fail而是让测试“带着伤跑”导致速度变慢、结果不稳定。我挑了几个最常见的做成速查表。报错场景为什么拖慢测试常见解法ResizeObserver is not defined每次遇到未定义 API 就抛错部分库会重试导致延迟在 setup 里补一个空的ResizeObserverstubwindow.matchMedia is not a function组件内调用 matchMedia 直接炸掉后续判断全乱在 setup 里 defineProperty 一个包含addEventListener等方法的 mockFailed to resolve component: RouterLink组件树里混入未安装的路由组件渲染链路被错误打断用global.stubs或安装真实 RouterNot implemented: window.scrollTojsdom/happy-dom 没实现该方法某些 UI 库内部逻辑不稳在 setup 里补空函数实现异步组件/动态导入用例间歇性失败没有等组件 resolve 就断言用例反复重跑浪费时间用flushPromises或vi.waitFor等待异步完成这些报错看着各自独立但它们有一个共同点让测试环境处于一种“半坏”状态。半坏环境下错误的 console 输出会变多、Promise 会挂着不释放、重试逻辑会被反复触发最终整体时长被一点点拖上去。所以我的经验是Vue 测试提速之前先把所有“环境缺失”类报错清零哪怕这些报错不影响最终断言通过也要清零。6. 常见问题与排查技巧实录这部分我把这些年碰到的高频问题集中整理一下方便大家当作速查表用。症状可能原因排查方向解决建议单独跑某个文件很快全量跑很慢存在全局 setup 或模块级共享状态检查setupFiles和全局 import精简全局 setup只放必需项一开并行就偶发失败端口、数据库、临时文件冲突看失败用例是否涉及共享资源资源随机化 进程间隔离用例偶尔超时重跑又过用例里有固定 sleep 或网络等待搜setTimeout改成vi.waitFor或轮询断言测试启动要十几秒全局装了太多重型 mock 或加载了大文件检查启动阶段的 import 链路动态 import 或剪裁全局加载组件测试报一堆 Vue warn子组件依赖未正确 stub 或未安装插件看 warning 里第一个提示统一在 setup 里补 stubCI 每次跑都很久但不失败没有利用测试分片/并行看 CI 日志里单任务时长按目录分片多个任务并行跑排查时我有个固定的先后顺序先看启动时间再看最慢的 Top 用例再看并发是否引入共享资源冲突。这三步做完90% 的慢测试问题都能定位到根因。还有一个非常值得养成的习惯把执行时间当成 CI 的重要指标来看待。我们会在 CI 上给测试任务设一个“软预算”比如全量单测单任务超过 8 分钟就告警。这样测速恶化不会悄悄发生而是立刻变成一道需要处理的问题而不是“反正一直都这么慢”。7. 最后分享几点我自己的体会做了这么多年单元测试提速我最想强调的一句话是不要一开始就想找到一个“魔法开关”把测试从 20 分钟变成 2 分钟。真实世界里慢是慢慢攒出来的快也要一层一层打回去。先测量、再减法、后并行、最后微观优化这个顺序我每到一个新项目都会重新走一遍每次都有新发现。还有一个很容易被忽视的点测试快了之后团队的开发习惯会变。原来大家不愿意写测试、不愿意跑测试不是因为懒而是因为测试反馈链路太慢。当单测能在两分钟内给出结果写用例就变成一种很自然的动作因为你能立刻看到它带来的安全感。这也算是我坚持做这件事的私心。最后分享一个小技巧本地开发时不要老跑全量用框架自带的“只跑改动相关用例”能力。Jest 有--changedSinceVitest 也有对应的模式配合 watch mode基本能做到每次保存文件后 1 到 2 秒内反馈。测试速度最快的状态不是全量回归跑得快而是大多数时候你根本不需要跑全量却依然对代码有足够的信心。
阅读完成 · 觉得有帮助?