系列: 与 AI 一起学编程

从 54% 到 100% 的测试通过率

三个周末,从 54% 走到 100% 测试通过率的历程。

54% 到 100% 全绿 三个周末,四个根因修复,一个诚实的数字 54% 63% 63% 100% 周六上午 周六下午 第 2 周 第 3 周
唯一无法被粉饰的数字:56 个测试中通过了 56 个。

当一个合作伙伴评估一个空间运行时时,他们应该问的第一个数字是集成测试的通过率。这是我们如何把自己的通过率从勉强能用带到坚不可摧的过程——以及为什么轨迹比某一个快照更重要。

不会对一个代码库撒谎的数字是测试通过率。销售数字可以被粉饰。星标数可以被灌水。代码行数可以被填充。测试通过率就是测试运行器说的那个数字,而测试运行器不在乎你的感受。

一周前的那个周六,我打开笔记本电脑时,这个数字是 54%。五十二个测试中通过了二十八个。不算灾难性。但也不是全绿。这种数字意味着代码库大体上能工作,但你说不清具体哪里不能工作。到那个周六晚上,数字变成了 63%(33/52)。到刚刚结束的这个周日,变成了 100%(56/56)。这些数字之间的路径,就是这篇文章要讲的。

为什么这个数字一开始就很低

几个相互交织的原因。

桩实现在通过本该失败的测试。 一个每隔几个周末就会出现的主题。套件里的一些测试是把返回值和零做比较,而桩实现恰好返回零,而这些测试恰好把它当成成功。测试框架没有撒谎。是这些测试本身是同义反复的。

一些断言和实际的错误码对不上。 有一个测试断言 font_set_data 在输入错误时返回 -1。而真实的实现返回的是 -3(它映射到 RAKU_ERROR_INVALID_PARAMETER,一个比这个测试所写的更具体的代码)。两种行为都是有效的。这个测试是在错误码被统一之前写的。修复方法是更新测试,让它接受任何负的错误码,而不是去改实现。

遥测测试没有状态可以追踪。 有一类测试通过断言”在我触发事件 X 之后,遥测系统应该记住它”来检验遥测事件管线。当时的遥测子系统有一个不记住任何东西的桩事件追踪器。测试失败不是因为管线坏了,而是因为当时根本还没有管线。

眼动追踪模块的返回值约定是反的。 一些眼动追踪 C API 函数在成功时返回 1,因为它们是某个带有 Win32 风格习惯的开发者某天写的。引擎的其余部分在成功时返回 0。针对引擎其余部分写的测试,在眼动追踪模块上失败了,不是因为代码写得不好,而是因为约定发生了漂移。

MSVC 构建在一个不透明句柄的类型转换上出现了硬编译错误。 XrInstance 是一个 OpenXR 的不透明句柄类型。为了序列化把它转换成 uint64_t,需要用 reinterpret_cast 而不是 static_cast。MSVC 大声报出了 C2440 错误。修复只是一行代码的改动。但它所隐藏的测试失败要大得多。

这项工作实际长什么样

我想把细节写下来,因为这种修测试的模式是可复现的。

周六上午(29/52 → 31/52): 解决了那些让一些测试连编译都通不过的构建错误。reinterpret_cast 的修复立刻解锁了两个测试,并暴露出第三个之前被编译失败挡住的测试。

周六中午(31/52 → 33/52): 把遥测桩替换成了有状态的事件追踪实现。这些桩是什么都不做的两行函数。真实的实现在一个线程安全的向量里追踪事件,暴露一个查询 API,并为测试框架里的三个遥测测试产出正确的行为。写了 telemetry_stubs.cpp 作为一个恰当的测试替身,足够贴近生产遥测子系统,让测试因为真实的原因而通过。

周六下午(33/52 → 33/52,数量没变,但质量有了提升): 修复了 test_edge_cases 里检查错误代码错了的断言。修复方法是让这个断言接受任何负的错误码,而不是它原本写的那个具体的 -1。这个测试现在真正检验了真实实现的错误路径。

一个周末之后(33/52 → 33/52 → 53/52 → 56/56): 眼动追踪的约定漂移花了最多的时间。眼动追踪的 C API 有一个和引擎其余部分不同的返回值约定。对齐这个约定需要同时更新实现(成功时返回 0)和更新调用方以期望 0。一旦对齐,一次性有二十个测试变绿了。级联效应是真实存在的。

再之后那个周末是 test_memory_leaks 那个周末。九个测试因为与内存相关的原因失败,而这些原因只有在泄漏检测框架下才会浮现出来。这些修复是那种不会变成一行代码的细致工作:InputQueue 原本是一个什么都不做的桩,需要真正的添加/获取/预测/裁剪行为;RollbackSession::initialize 必须在接受回调之前对其进行验证;NetworkQualityEstimator 必须真正从发送/确认对中追踪往返时延和丢包率;而 ECS World::clear 必须清空空闲索引队列,以防止下一个分配周期重用过时的索引。

最后那个(ECS 空闲索引队列)是那种不会产生崩溃,但会在之后产生极其隐蔽的 bug 的问题。空闲索引队列是实体组件系统在实体被销毁后如何重用句柄的机制。如果 clear 在队列里留下了过时的索引,下一个被创建的实体就会得到一个与之前被删除的实体重叠的句柄,而指向那个被删除实体的引用会悄无声息地开始指向新的实体。极难诊断。极易引入。内存泄漏测试之所以能抓到它,是因为泄漏检测器追踪了哪些句柄已经被分配出去了。

到那个周末结束时,测试套件达到了 56/56。100%。

我学到了什么

三件事。

约定漂移在你测量它之前是不可见的。 眼动追踪模块一直在孤立地工作着。它在成功时返回 1、而引擎其余部分返回 0 这个事实,一直没有咬过任何人,因为没有人写过跨模块的测试来检验它。一旦测试套件大到足以跨越多个模块,它就一次性以二十种不同的方式暴露出了这个漂移。测试套件正是约定得以被审计的方式。

能通过测试的桩比会失败的桩更糟糕。 两者都是桩。两者最终都需要真实的实现。会让测试失败的桩,诚实地承认自己是个桩。而恰好能通过测试的桩,是代码库在对自己撒谎。能揭露这些撒谎的桩的审查,是最能改善代码库的那种审查。

级联效应才是真正的奖赏。 眼动追踪的约定修复,一次推送就解锁了二十个测试。ECS World::clear 里的内存泄漏修复又解锁了九个。测试通过率上的大胜,不是来自修复二十个单独的 bug。它们来自修复四个根因问题,每一个都有多个下游的测试失败。

合作伙伴和开发者应该从中获得什么

如果你正在为合作评估一个引擎,而测试通过率低于 90%,去问问它的轨迹。一个三个周末内从 54% 走到 100% 的团队,和一个在 80% 停留了六个月的团队是不同的团队。

如果你在运行一个智能体驱动的工作流,而你的测试通过率没有达到你想要的水平,不要假设是智能体需要变得更聪明。去看测试本身。那些同义反复的测试,约定的漂移,恰好能通过的桩。修复通常在测试框架里,而不是在实现里。

如果你是一个正在为自主 PR 构建编码智能体的 AI 实验室,我发现最具预测性的指标是”在智能体的 PR 落地之后,测试通过率是否上升了”。很多智能体落地的 PR 既发布了代码,也发布了针对那段代码能通过的测试,但对真实产品的实际覆盖率没有任何提升。能推动集成测试通过率上升的智能体,是不一样的智能体。值得为此去优化。

一周前的那个周六,这个数字是 54。今天晚上,它是 100。测试运行器不在乎我的感受。测试运行器在这一点上是对的。

周日带着一个全绿的套件合上笔记本电脑。下周末回去继续构建。

在一个能证明自己质量的运行时上构建

RakuAI 是你的助手栖身在真实世界中的那个 AI 原生空间运行时。全绿的测试,诚实的信号,引擎级别的纪律——看看你的团队能在它之上发布什么。

← 所有文章