Loading...
Loading...
最近一段时间一直在优化Aevia的测试系统,今天做完后想了想,它断断续续的好像已经维护了差不多三四个月了,但一直没有专门聊过它,所以趁着这个机会,发个博客来讲一讲Aevia的这一套Benchmark测试系统。

说起来,在做 Aevia 这个项目之前,我其实并没有特别系统地思考过“测试系统”这件事,我以前对测试的理解比较简单——让AI写几个单元测试,跑一下构建;或者在浏览器里手动点点看,只要功能没有明显坏掉,好像就算完成了一次验证。
但这个项目做到后面,我越来越发觉到,有些场景真的很极限,极限到我人工测试基本无法做到稳定复现,更观察不到可靠的结果。比如两个人同时在同一个区域落笔,甚至同时修改到了同一个像素点,这种“操作撞车”最终到底会不会导致两端画面不一致?又比如,一个用户因为网络延迟,某些数据包比后发的数据更晚到达,那么这批迟到的数据在其它客户端的页面上还能不能重新回到它本来应该处在的历史位置?再比如,一个新用户进入房间时,服务端一次性向它同步几万甚至十万级别的历史点数据,那么这个用户到底多久能看到第一帧内容,又多久能看到完整稳定的画面?
简单想一想就能知道,这些问题靠手测几乎是不现实的,因为我自己手动操作浏览器,最多只能模拟一些很粗糙的路径。我要同时打开两个窗口,分别登录两个用户,再靠手速去制造并发和大量数据,还要靠肉眼判断两边最后有没有一致。这个过程不仅慢,而且非常不准确。
更麻烦的是,即使我真的把某个问题复现出来了,我看到的也只是最终表现,系统内部到底是协议同步错了、排序错了、渲染错了、脏区重绘错了,还是浏览器本身调度慢了,对我来说仍然是黑盒——我看不到系统内部执行的细节。
最初,我也想过用Dev Tools的Performance火焰图来测,这个工具确实非常好用,我也并没有完全抛弃它,直到现在也都还是我的测试手段之一,因为它可以通过火焰图测到每个调用栈、每个函数的执行过程和耗时,甚至能落实到具体的代码行数,这是哪怕现在这个最新版本的Benchmark系统都测不到的指标,这也是我现在在某些时候依然选择使用它的原因,但它的问题也很明显——部分极端场景无法轻易复现,以及测量环境容易受到抖动干扰。
因此,Aevia 的测试系统最开始只是有一个很朴素的需求——让浏览器自己动起来,让脚本帮我制造那些人手很难制造的极端场景,再把结果以一种结构化的方式记录下来。
这就是整个测试系统的起点。
最开始,我选择 Playwright 的原因很简单——因为它能像真实用户一样操作浏览器。
对于 Aevia 来说,这个能力非常关键。因为协同白板的很多问题不是纯函数执行的问题,也不是接口返回值的问题,而是一个完整的链路问题:用户输入、前端采点、Canvas 绘制、WebSocket 发送、后端广播、另一个前端接收、命令入队、远端重绘,一个数据要经过层层处理,最终才会反映到另一个用户眼前。
如果使用单元测试的理念,只测某一个函数,我当然能知道这个函数在某种输入下的输出是否正确,但我无法知道整条链路是否真的能在浏览器里跑通,也不能直接获取到用户的连贯使用体验。
而 Playwright 正好可以填补这个空白。它可以打开两个甚至多个浏览器页面,模拟不同用户进入同一个房间,然后分别执行鼠标移动、落笔、抬起、撤回、重做、翻页等操作,真正的站在用户视角,通过真实浏览器去驱动页面。
最初的测试脚本就是在这个背景下写出来的,不过还称不上是一个系统,更像是一批独立的自动化 case。例如:
这个阶段的测试脚本解决了一个最现实的问题:它让我从“靠感觉看一下”进入了“可以稳定复现场景”的阶段。
这对当时的我来说已经非常有价值。因为 Aevia 的协同算法本身就依赖很多极端场景来验证,比如 Lamport 时间戳排序、命令级包围盒、脏区域局部重绘、全局哨兵队列等。如果没有自动化脚本,我几乎不可能稳定地知道这些设计到底有没有抗住高并发和弱网。
但这个版本的问题也很明显——它更像是一堆散落的工具,而不是一个统一的测试系统。每次我想测什么,就只需要单独运行某个脚本。细粒度确实很高,但脚本之间没有统一的报告格式,也没有统一的用例组织方式,更没有一套完整的性能指标。它能回答“这个 case 大概能不能跑通”,但不能回答“这个系统整体表现怎么样”。
后来,我接触到了 benchmark 这个概念,于是我就开始想把这些零散脚本收拢起来。
一开始我对 benchmark 的理解也很粗糙,大概就是“性能测试”。但随着 Aevia 的测试场景越来越多,我慢慢意识到,benchmark 更重要的价值其实不只是测快慢,而是提供一个稳定、统一、可重复的度量框架。
也就是说,我不应该每次都临时写一个脚本,测完之后看一下控制台输出就结束了。我应该有一个统一的入口,它能够把多个测试 case 编排起来,按照固定的方式运行,并输出结构化报告和可视化报告。
于是,第一个 benchmark 编排层就这样实现了,它相比最初的 Playwright 小脚本,最大的变化是“系统化”。
我开始把测试拆成多个环境和测试样例,而不是一个个孤立脚本。它能够一次性运行多个测试场景,并在最后统一输出结果。这个阶段也开始形成了一些后来一直保留下来的设计思想,比如我针对CPU和GPU的状态采取了四个不同层次的环境,某些环境中开启GPU,有些不开启,有些将CPU的计算速度降低4倍,有些则不限制,这样就可以模拟出不同性能水平的设备在各个测试样例中的表现,让整个数据更具有说服力,这个设计理念直到现在都还一直存在,也算是传家宝了(笑
随着测试系统的演进,当时的测试覆盖面也逐渐扩展到了更多的维度:
这个版本对我的帮助非常大,因为它第一次让我能够从一个比较宏观的视角观察 Aevia 的性能演进。每次我优化渲染算法、改动脏区计算、调整数据结构之后,我不再只能凭肉眼说“好像快了一点”,而是可以看到 benchmark 里某些指标确实在变好。
例如,全量渲染耗时变短了,远程同步延迟下降了,某些高压场景下的内存增长没有之前那么夸张了。这些变化都会在 Benchmark 结果中直接反馈给我,让我知道优化方向大体是正确的。
当然,这个版本也还远远算不上成熟,它最大的问题是——测得太粗糙了。
随着 Aevia 的复杂度继续上升,我逐渐发现第一个 benchmark 编排层存在很多不严谨的地方。
首先是环境抖动,当时很多 case 只跑一次。一次测试结果非常容易受到浏览器启动状态、系统后台进程、GC、CPU 调度、网络瞬时波动等因素影响。如果某次 full-render 比上次慢了 500ms,我其实很难判断这是代码真的变慢了,还是这次浏览器刚好卡了一下,也就找不出真正的优化方向。
其次是没有预热,因为浏览器和 JS 引擎并不是每次一启动就立刻进入稳定状态。V8 的 JIT、缓存路径、Canvas 上下文初始化、页面资源加载,这些都会影响第一次运行的结果。如果直接把第一次测量当成正式结果,很容易把冷启动成本和真实运行性能混在一起。而真实场景中,用户不可能是直接通过双击桌面图标一步进入到白板页面的,而是先有一个浏览器的启动的过程,再通过url进入到前置的登录页面,最后再通过 Vue 的路由机制跳转到路由页面,这个成本完全不是冷启动能比的,所以有没有预热对于最终测试结果的影响会非常明显。
再者,统计指标其实也不够严谨,当时很多结果只有一个最终耗时,没有中位数、平均数、P95、最大值这些经典的统计信息。一次 benchmark 跑完后,我只能看到一个数字,但不知道这个数字是稳定的,还是某次偶然测出来的,也没有很完整的证据指出这个指标是否真实。
接着就是,这个测试系统并不是很严格的回归系统,也就是说,它无法与历史记录中的数据比对,也没法固定一个基准水平,每一次测出来的数据都是独立的,互相之间没有任何关联,比较变化仍需要我人为进行,无法由系统判定是否回归,徒增成本
还有一个更隐蔽的问题是测量口径——很多性能测试很容易犯一个错误:把脚本执行完成的时间当成用户实际看到结果的时间。比如测试脚本发送完所有数据,或者等待某个 Promise resolve,就把这个时刻当成全量渲染结束。但脚本执行完成并不一定等于浏览器已经把画面渲染出来了,也不等于用户已经看到了稳定画面。
这对 Canvas 应用尤其危险,因为 Canvas 绘制、浏览器合成、rAF 调度、主线程任务之间存在很多细节。如果不明确起点和终点,最后测出来的数字虽然看起来很精确,但实际上可能根本不是我想测的东西。
另外,当时的测试也过度依赖 headless 模式,也就是无头浏览器模式,它不会打开真是可见的浏览器窗口,而是只运行一个浏览器内核。这种模式很适合自动化,但它不完全等价于真实用户打开浏览器窗口。很多时候,我还需要 headed 模式来观察真实窗口中的表现,尤其是渲染耗时、交互响应这些问题。
于是,我又对 benchmark 做了第二次比较大的重构,这次重构的目标不是扩展更多 case,而是让测试本身变得更严谨。
所以,这个版本加入了一些更严谨的优化和约束:
这个版本已经有一点成熟 benchmark 系统的样子了,它不再只是跑几个 Playwright 脚本,而是有了测试套件、用例、运行模式、报告、回归统计和性能矩阵。对于当时的系统来说,这已经能覆盖相当多的协同场景,也能比较稳定地帮助我判断算法优化是否有效。
这也是我使用时间最长的一个版本,但这个原因并不是因为它好用,恰恰相反,是因为它太难用了,这也是我最近几天对整个测试系统进行大范围重构的最大原因。
为了更准确地知道系统内部什么时候开始渲染、什么时候完成渲染、什么时候处理增量、什么时候触发脏区重绘,我在 benchmark 系统里加入了很多内部埋点和钩子,这些埋点会在业务代码运行时主动上报一些关键事件,比如:
从短期来看,这种方式很有效,因为它能直接告诉我系统内部发生了什么。某个 case 慢了,我可以看埋点上报,判断是数据生成慢、协议同步慢、渲染慢,还是报告生成慢。它像是在系统内部插了很多探针,最终也能十分细粒度的告诉我非常多的指标,让原本黑盒的流程变得可观测。
但随着项目继续迭代,这种方式的缺点也变得越来越明显了。
首当其冲的问题就是稳定性,benchmark 内部埋点高度依赖业务代码结构。一旦我改了某个调用链,或者把渲染逻辑改写成了新的模块,或者调整了某个队列的执行方式,埋点可能就会失效。它不是因为业务真的坏了,而是因为测试系统依赖的那个内部事件不再被触发了,或者系统逻辑直接绕过了原本的采集链路。
这会导致一个很麻烦的结果——测试失败时,我首先要怀疑的不是业务代码,而是测试链路。
例如,我只是改了一个渲染调度逻辑,功能上看起来完全正常,但 benchmark 里很多项目却突然失败。原因可能只是某个 recordRender 或 recordIncremental 调用不再走原来的路径,导致测试拿不到它想要的数据,一直原地等待直至超时。
这样一来,benchmark 就变成了另一个需要被手动维护的系统,每次我改业务,都要顺手检查埋点有没有断。每次 benchmark 失败,都要先排查是业务失败还是埋点失败。它本来是为了减少手动测试成本而存在的,最后却逐渐变成了一种“手动维护式的 CI”。
这也是它无法直接进入 CI 流程的根本原因,CI 的价值在于自动化、稳定和可信。如果一个测试系统本身经常因为内部实现细节变化而失效,那么它放进 CI 里只会制造噪声。每次 PR 红了,我都要花时间判断这是不是测试系统误报,我要把 CI 会执行的流程先在本地达到可行,然后再让 CI 去跑,这就违背了 CI 的意义,最终的结果也说明不了任何东西。
也是因为这个原因,这个系统的回归测试几乎等同于不存在,因为我基本无法保证每次测试都得到一个“全都通过”的结果,总会发生某些测试因为逻辑问题无法通过的现象,导致我无法设置baseline,也无法准确的比较每一次测试结果。
简单来说,就是整个测试系统与项目核心代码的耦合度太高了。
到这里,我开始意识到,Aevia 的测试系统必须换一种思路——它不能再站在业务内部看自己,而应该站在用户和浏览器外部看结果。
因为以上的种种原因,我设计了一套基于外部观测手段来采集指标的 External Benchmark 测试系统,它的核心思想可以用一句话概括:不再相信业务代码主动告诉我的数据,而是直接观察用户最终能看到什么。
在之前的内部埋点版本中,测试通常关注的是“系统内部说自己完成了什么”,而 External Benchmark 关注的是“浏览器画面上实际发生了什么”。这两者看起来都在测性能,但可信度完全不一样,因为如果用户看不到内容,那么无论内部上报了多少“渲染完成”,都没有意义;如果两端最终截图不一致,那么无论内部命令队列看起来多么合理,用户体验上就是错的。
因此,在新的外部观测模式里,我尽量把测试系统和业务代码隔离开,让它不侵入核心代码,手动向外暴露事件和数据。不再读取 Pinia、不再读取 Vue 组件实例、不再依赖内部埋点,不再要求业务代码主动调用钩子函数。而是只使用这些外部信号:
这种设计让测试系统变得更像一个外部观察者,而不是内部系统的一部分,它不关心内部某个函数有没有调用,也不关心某个业务对象里有没有某个字段,只关心用户进入房间后多久看到内容、远端画一笔后本端多久出现像素变化、本地落笔后画面几帧内响应以及两个用户最后看到的画面是否一致等多个直观的指标。
现在这套测试模式里最核心的技术点就是 Canvas 外部观测。
因为 Aevia 是一个 Canvas 白板应用,最终结果都体现在画布像素上。如果我想在不侵入业务代码的前提下判断页面有没有渲染出内容,最直接的方法就是读 Canvas 像素的状态。
具体做法是,使用 Playwright 在页面里注入一个外部 observer 脚本。这个脚本不读取业务变量,也不调用业务函数,只做一件事——找到页面上的 Canvas DOM 元素,把指定区域拷贝到一个测试侧创建的离屏 Canvas 上,然后从离屏 Canvas 读取 RGBA 像素数组。
因为把屏上 Canvas 的内容画到离屏 Canvas 上,并不需要拿到业务 Canvas 的绘图上下文。浏览器允许直接把一个 Canvas 元素作为 drawImage 的图像源:
offscreenCtx.drawImage(sourceCanvas, sx, sy, sw, sh, dx, dy, dw, dh)真正调用 getImageData 的是离屏 Canvas 的上下文,也就是测试脚本临时创建的 Canvas 对象,而不是业务 Canvas 的上下文。这样做的好处是测试系统不会介入业务渲染上下文的状态,只是把最终显示结果当成一张图像来采样。
拿到像素之后,测试系统会计算两个核心指标,第一个是 nonBlankRatio,也就是非空像素比例。
它的逻辑很简单:如果一个像素不是接近白色,就认为它有内容。
大致判断是:
alpha > 0 && (r < 245 || g < 245 || b < 245)然后:
nonBlankRatio = nonBlankPixels / totalPixels这个指标主要用于判断画布是否从空白变成有内容。例如 full-render 场景里,新用户进入房间后,我不再等内部上报“全量渲染完成”,而是观察 Canvas 什么时候第一次出现非空内容。
第二个是 diffRatio,也就是两个采样之间发生明显变化的像素比例。
它会逐像素比较前后两次采样的 RGBA:
delta = |r1-r2| + |g1-g2| + |b1-b2| + |a1-a2|如果 delta 超过阈值,就认为这个像素发生了变化。
最后:
diffRatio = changedPixels / totalPixels这个指标主要用于判断局部区域是否出现新内容。
表面上看,逐像素比较好像会很重,但实际上它有两个重要的降本设计:
首先,它不总是读整张 Canvas,而是读 ROI ( Region Of Interest ),也就是感兴趣区域。比如我知道测试会在 (480, 330) 附近画一笔,那么我只要观察这附近一个小矩形区域就足以获取到测试需要的全部数据了,不需要每帧都扫描整张画布。
第二,它会缩放采样,一个 ROI 可能是 220x220,但采样时可以缩到 64x64 或 72x72。这样每帧真正参与比较的像素只有几千个,而不是整张画布的上百万个像素。
这就是测试系统能够用 rAF 逐帧观察画面变化的原因——它不是做复杂图像识别,而是做一个很便宜、很直观、很可解释的像素变化判断,使用低成本的方式获取高可信度的结果。
在这套新的测试模式里,我没有用同一种方式粗暴测所有性能,而是按场景拆成了三种口径:
第一种是全量渲染,也就是一个用户后加入房间时,需要加载大量历史数据并把画面还原出来的过程。
这个场景的核心问题是:用户多久能看到内容,多久能看到稳定画面。
所以它的指标是:
firstNonBlankMs:首个非空画面时间。visuallyStableMs:视觉稳定时间。nonBlankRatio:最终非空像素比例。longTaskCount 和 longTaskTotalMs:主线程长任务情况。这里允许 1 到 3 帧误差,因为全量渲染本身是一个大粒度任务,尤其是 10000、50000、100000 点这种规模下,总体耗时基本都处在100ms到500ms区间,十几毫秒的 rAF 采样误差并不会改变整体结论。
第二种是远端增量绘制,测的是另一个用户通过 WebSocket 发来一笔之后,本端多久出现第一个像素变化。
这个场景不能用“等待稳定”作为主指标。因为远端增量的响应本来可能只有十几毫秒,如果我非要等 2 到 3 帧稳定,最后测出来的结果就会发生严重的偏移,毕竟一帧的响应速度和五帧的响应速度是有很大的差距的,所以它的主要指标是:
remoteFirstPixelMs:远端发送后,本端 ROI 首次出现像素变化的时间。framesToFirstPixel:经历了多少帧。roiDiffRatio:ROI 变化比例。这里最重要的是帧数,一帧响应和三帧响应在全量渲染里可能差别不大,但在实时协作里差别非常明显,所以检测的细粒度会很高,尽量排除帧率对最终结果的影响。
第三种是本地实时绘制,它测的是用户自己落笔后,本地 Canvas 几帧内出现变化。
这个场景对交互体验最敏感。用户手指或鼠标已经动了,如果画面两三帧后才响应,就会明显觉得“跟手性”差,所以它的指标是:
inputToFirstPixelMs:输入到首像素变化时间。inputToFirstPixelFrames:输入到首像素变化帧数。inputDelayMs:浏览器 Event Timing 里的输入延迟。longTaskCount:主线程长任务数量。这三种口径对应的是三种不同的用户体验问题,全量渲染关心“进入房间后多久能看到内容”,远端增量关心“别人画了我多久能看到”,本地实时关心“我自己画时跟不跟手”,三种不用的测试指标能够反映出系统不同部分的性能水平和协同表现,也能够给我更加全面的反馈。
除了性能,这个 Benchmark 还承担了正确性验证的职责。
协同领域最核心的问题,归根结底其实就是一个——所有人最终看到的画面是否一致。
大部分 Benchmark 主要检测的是内部数据一致性,这当然很重要,但站在用户的角度,真正关心的其实是画面。如果两个客户端内部命令队列看起来一致,但最终渲染结果不一样,那仍然是失败。如果内部顺序有一些实现差异,但最终画面完全一致,在用户体验上反而是可以接受的。
因此在正确性验证的 case 里,我使用了截图和 pixelmatch 这个库,它会分别截取两个页面的主 Canvas,解码成 PNG,然后用 pixelmatch 做逐像素比较,最后输出:
diffPixels:不同像素的数量。diffRatio:不同像素的比例。passThreshold:最低允许不一致的比例阈值。这样一来,测试失败时我不需要只看控制台数字,还可以直接打开 artifact,看 Page A、Page B 和 diff 图到底哪里不一样。
这对排查协同问题非常有帮助,比单纯看日志直观得多,也省去了我一个个场景复现和排查的成本。
另一个重要改造是采样和统计,现在的测试默认有以下几种参数
warmup = 1
runs = 3warmup 不进入最终统计,它的作用是预热浏览器、JIT、Canvas 上下文和一些缓存路径。
runs 是正式采样次数。每个正式 sample 都会记录自己的指标,最后再聚合成 case 级别的统计结果。
当前聚合会计算:min、median、mean、P95 和 max 这几种指标,在最终判定里,我会更看重 median,也就是中位数,而不是平均值,原因也很简单——性能测试很容易出现离群值。如果三次测试分别是 100ms、110ms、800ms,那么平均值会被 800ms 拉得很高,但中位数仍然是 110ms,更接近常态表现。
同时,测试系统还会做样本质量过滤,例如 full-render 里,如果某次 sample 的 nonBlankRatio 明显低于同组样本的正常范围,就说明这一轮可能没有画出预期内容,不能把它当成有效性能样本。
对性能耗时本身,系统还会用 MAD 和 robust z-score 做离群过滤。
MAD 是中位数绝对偏差,就是样本中每个数据离样本中位数的绝对值偏差,它比标准差更抗异常值,因为标准差很容易被一个极端慢样本拉大,但 MAD 更关注大部分样本的正常波动。
这里还会先对性能值取对数,因为性能变化更适合用比例理解。比如 10ms 到 20ms 是 100% 变慢,而 200ms 到 210ms 只是 5% 变慢。它们绝对差值都是 10ms,但意义完全不同。取对数后,比较的是比例变化,和性能回归的直觉更接近,核心是一个非常简单的对数计算规律:
logA - logB = log(A/B)由此可见,如果只是将原始值简单的相减,那它最终得出来的就不是一个相对性的变化,但如果是取对数后再相减,最终的计算结果等同于将两组数据相除再取对数,显然能够比较出两者之间的倍数关系。
这部分听起来有点复杂,但它最终解决的是一个很朴素的问题:不要让偶发抖动污染整个 benchmark 结论。
当 external benchmark 能稳定输出结构化指标后,下一个问题就是:这些指标应该怎么判断好坏?
最直接的方法是使用 baseline,也就是选定某一次我认为可信的测试结果,把它保存成基准。以后的每次 benchmark 都拿当前结果和 baseline 比较。
每个 case 都会按环境区分,例如:
gpu_cpuHigh::full-render-10000它表示 GPU 开启、CPU 高性能环境下的 10000 点全量渲染 case。
当前 baseline 回归判断主要关注:
阈值上,我没有采用“只要比 baseline 慢一点就失败”的策略,因为当前这套外部观测的测量口径有一个明显且无法忽视的问题,就是很容易受到噪声的影响,它天然不如埋点采集到的指标精准,这也代表性能测试天然会有噪声。
因此当前设计里,±10% 以内的变化会被认为是正常波动。真正判硬回归时,会手动计算阈值:
allowedMax = baseline + max(absoluteAllowance, baseline * regressionPercent)其中默认:
noisePercent = 10
regressionPercent = 20
regressionAbsoluteMs = 8也就是说,如果某个指标只是慢了 5% 或 8%,系统不会大惊小怪。只有它明显超过容忍范围,才会判定为回归失败。
这个设计比早期版本理性很多。
早期 benchmark 更像是“测一个数字,然后凭感觉判断”。现在则是“有基准、有阈值、有变化趋势、有失败原因”。
尽管 baseline 解决了固定基准对比的问题,但它仍然不完美,因为 baseline 设定的阈值本身也可能不科学,如果某次 baseline 是在一个偶然很快的环境下测出来的,它就会太严格,后续很多正常结果都会被误判成回归;反过来,如果 baseline 是在机器很卡的时候测出来的,它就会太宽松,后续真实回归也可能被放过。
所以,我就寻思,能不能仿照人工智能的设计理念,让系统在每次测试结束后,通过一些方式学习和统计测试水平的变化,自动调整基准值和阈值呢?
秉持着这个思想,我就设计出了一个基于历史测试纪录的统计学习机制,这是一套稳健的统计系统,利用历史数据和统计学原理,学习每个指标在当前环境下的正常波动范围和变化,大概原理如图所示。

首先,它会把历史数据分成两个通道。
第一个是 normal channel,也就是正常通过的性能指标,例如:
firstNonBlankMsMedianvisuallyStableMsMedianremoteFirstPixelMsMedianframesToFirstPixelMedianinputToFirstPixelMsMedianinputToFirstPixelFramesMedian这些数据本身就会在测试过程中被采集出来,后续也会被用于学习“正常性能范围”。
第二个是 anomaly channel,即记录异常现象,例如:
这个设计很重要,因为如果失败样本完全不进入历史,测试系统永远都不知道“失败是不是经常发生”,学习和调整的范围就永远会在一个小区间里面打转。尤其是当无效样本比例持续偏高时,这个学习机制就会认识到这不一定代表业务性能退化了,更可能说明测试规则本身过严,比如 ROI 选错了,nonBlankRatio 门槛不合理,或者数据生成方式导致内容分布太稀疏,导致计算出的结果始终不准确。
因此,正常性能值进入 normal channel,失败和异常现象进入 anomaly channel,两条通道会被分别学习,然后计算结果。
学习机制的核心理念总结成数学公式大致是:
historyLogs = log(historyValues)
learnedMedian = median(historyLogs)
learnedMad = MAD(historyLogs, learnedMedian)
learnedSigma = 1.4826 * learnedMad
noiseBand = max(learnZScore * learnedSigma, log(1 + noisePercent / 100))
learnedUpperBound = learnedMedian + noiseBand也就是说,它会根据历史记录中的中位数,计算出MAD,再转换成一个类似正态分布的标准差,基于这个标准差,以及一些去噪步骤,它会得出一个正常的波动区间,在后续新的测试中,我就能通过这个阈值,判断落在这个区间内的数据就是当前版本下相对正常的数据,而超出区间的数据就是异常数据,用这个方式来判定回归,会比写死阈值的方式可信得多。
在这个模式下,如果当前值的 log 超过 learnedUpperBound,就说明它超过了历史正常波动范围,但系统不会立刻把它当成确认回归,它会先看最近几次历史里有没有重复超界。如果只是单次异常,就判定为 suspected,也就是疑似回归。如果最近窗口内已经多次出现类似问题,才判定为 confirmed,也就是确认回归。
它的默认策略是:
failOnPerformance = confirmed也就是说,疑似回归只显示在报告里,不会让 CI 失败。只有确认回归才会让命令退出码变成 1。
这个策略对 CI 非常重要,因为 CI 最怕的是不稳定,如果性能测试因为一次偶发抖动就让整个 PR 失败,那开发者很快就会对它失去信任。而学习机制的作用,就是让系统尽量区分“偶发噪声”和“重复问题”,不让偶发事件影响整个结果。
同时,它还会判断 baseline 是否健康,如果历史常态明显慢于 baseline,就说明 baseline 太严。如果历史常态明显快于 baseline,说明 baseline 太宽。如果最近多次稳定优于 baseline,并且波动很小,系统还会建议更新 baseline。
这就让 benchmark 从“固定阈值判断”演进成了“根据历史趋势动态调整”。
前文中,我们一直在聊 Benchmark 的设计。诚然,它解决了系统级外部观测问题,但它不等于测试体系的全部。
因为一个项目不可能只靠 E2E 和 benchmark 就能保证质量,很多问题应该在更小的层级被发现。例如协议转换、二进制编解码、dirty rect 计算、后端密码校验、HTTP route,这些都不需要打开浏览器跑完整协同链路,而是通过一些小的单元测试和模块测试就能测出来,相比之下,benchmark 反而太重了。
因此,后来我又引入了 Vitest 作为统一编排层,它在这里的定位不是替代原本的 External Benchmark,而是补齐单测、模块测试、集成测试和微基准这些细粒度比较高的指标。
当前的测试主要被拆成以下几个 project:
shared-unit:测试前后端共享协议,也就是 shared 文件夹里的共享资源。backend-unit:测试后端 service 和 WebSocket 纯逻辑。backend-integration:用 supertest 测后端 HTTP API。frontend-unit:测试前端工具函数和轻量状态。frontend-module:测试前端的模块化函数。frontend-browser:用 Vitest Browser Mode 和 Playwright 测轻量组件交互。micro-bench:用 Vitest bench 测纯函数和模块级性能。这样一来,测试体系的层次就变的更加完整了——Vitest 负责小颗粒度、确定性更强的验证。External Benchmark 负责多端协同、真实浏览器、Canvas 外部观测和系统级性能。最后再通过一个 Aggregate 聚合脚本,把 Vitest 、Vitest bench 和 Benchmark 的测试结果汇总成统一报告。
这个设计的好处是职责清晰,比如二进制编解码出错,不需要等 External Benchmark 跑到远端同步失败才发现,Vitest 在单测中就能直接定位。反过来,如果两个用户最终画面不一致,单测可能完全发现不了,但 Benchmark 就能通过相关测试把问题暴露出来。
这也是我后来对测试系统理解上的一个变化:不同测试不是互相替代,而是各自站在不同层级上兜住不同风险。
到现在为止,Aevia 的测试系统大致形成了一个三层结构。
第一层是 Vitest,它负责 shared、backend、frontend 的单测、模块测试、集成测试、浏览器组件测试和微基准,它的特点是快、确定性强、定位清晰。
第二层是 External Benchmark,它负责更大角度的对不同的系统运行链路进行测试,采集性能水平和协同表现,真实、多端、黑盒、贴近用户体验。
第三层是报告和回归系统,它负责 baseline 对比、history learning、异常学习、报告生成、CI summary、timestamp 历史归档。
并且,测试系统的使用也非常灵活,不仅仅是测试系统与核心代码解耦了,各个测试模块之间的耦合度也很低,如果想观察使用体验,可以运行:
npm run test:all:local它会依次构建项目、运行 Vitest 测试、运行 external smoke、运行 external benchmark,并最终输出统一报告。
如果是 CI 环境,则可以只跑:
npm run test:ci它会卡构建、单测、集成、浏览器测试和 correctness smoke,不强制跑完整性能矩阵。
针对一些耗时比较长的测试样例,也可以跑:
npm run benchmark:nightly这样性能测试可以继续积累历史样本,学习机制也会逐渐变得更有参考价值。
回头看整个过程,我觉得这次测试系统改造真正解决的不是“多写了几个测试”这么简单,它真正解决的是测试可信度的问题。
从最初的 Playwright 脚本解决了“人手难以模拟极端场景”的问题,再到第一个 benchmark 编排层解决了“测试用例零散、缺少统一报告”的问题,紧接着第二个 benchmark 重构解决了“没有 warmup、没有多次采样、没有统计口径”的问题,后来的内部埋点版本解决了“系统内部过程不可见”的问题,但同时也带来了“强依赖实现细节”的问题,直到现在的 external benchmark 解决了“测试系统太脆弱、无法上 CI”的问题,整个测试系统都在一步步的迭代和进化,这些演进不是一开始就设计好的,而是在项目复杂度不断上升的过程中一步步逼出来的。
这也是我觉得这个测试系统比较有价值的地方。它不是为了堆技术名词而存在,而是每一步都对应了一个真实痛点。
当然,我也不敢说现在这个版本就完全成熟、非常完美了,我相信未来我肯定还会发现更多的业务痛点,并持续优化。
这次长达几个月的测试优化经历,让我对前端工程化的水平不断提升,这段时间,我不仅是在不断地迭代测试系统,同时也在通过测试系统的反馈不断地优化这个协同白板项目本身,总体来说,测试和业务是相辅相成的,我一开始也是在优化了业务性能后才想要做一个测试脚本来测试具体的提升水平,最后也是通过不断地优化这个测试脚本,来反向的促进了整个系统的进化,总的来说,也算是非常有意义的一次尝试。