EnriqueMark
设计模式与架构

测试的两面

2026-09 简体中文

以测试本身来说,完善覆盖的深度是没问题的。但这对于保证一个稳健的程序运作来说是不够的。因为也许测试很详细的覆盖了函数体自身的逻辑、产出的结果,乃至是整体的e2e。但对于输入本身,却始终是建立在预设前提之下的。

例如关于输入数据的假定。此前谈到 基于属性的测试 时我有聊过这方面——关于输入,这部分是有盲区的。而在这个地方,最适合的就是引入PBT。

经典的测试构建会使用假数据或者是人为(如今是AI)构建的数据来对被测对象进行攻击。但这里的显著问题当然就是「我如何才能测到我想不到」的部分?即便交给AI,它也会受到上下文的影响,仍然会产生忽略的部分。哪怕函数体的单元测试和e2e都已经做的很详细了,最终仍然出现了不可预料的bug。这个坑我踩到了,复盘之后,我发现之所以导致这个问题的原因在于「维度」。

我发现自己的测试过多集中于「逻辑和输出」,却忽略了「输入」。这就导致了上述所说的问题,尽管逻辑没问题,在可预期的情况下输入常规数据进行e2e也没问题,但在真正运行时却撞上了「很常见」却没被我测到的情况——即便在单一维度打的再深,没覆盖到的地方也还是没覆盖到。此问题的关键即在于「数据」,输入的数据是一个我在测试时预料之外的情况。这里的预料之外当然也有我在经验上的局限性,但在结构上和「不知道自己不知道」什么这点是共通的。

因此,我尝试寻找解决办法——这就来到了PBT,此前我一直没正式引入,因为觉得不太有必要而且很繁琐,要加额外的套件。那能否引入一个轻量级的,即只采取其思路,但先不上重型设施呢?在这样的思考之下,我开始在输入侧进行补强。但即便如此,如何构建生成器也是一个需要考量的地方,毕竟数据有问题,再怎么打也测不出实质——一开始我想到从已有数据去构建分布,但是转念一想,这么搞对于新功能就废了,没数据的情况下怎么办?因此,不能取具体的数据,必须取结构,即在结构之上,其产出可能是什么样的?而且要用PBT还有另外一个问题,那就是这里的值可能不是连续值,而是不同状态交叉产生的值,怎么明确到底要测哪些状态?

到这里就得引入一个新的测试概念,那就是 组合测试 ,其恰恰就是给这种多输入参数的组合场景所用的。在组合测试之中,它首先就要求测试者去明确定义每个状态的意涵,换言之明确当前的状态边界,或者说它的空间有多大——这里的定义不是指类型,是明确的定义其「业务逻辑」,以及在逻辑中该状态到底干什么用的。比如存在状态A,我需要明定该状态A的具体意涵是什么、这个状态A在该输入之下应当被如何处置、以及为什么要按照前述那样去处置它。光是在这个过程中,许多潜在空间就被消除掉了。

在完成了对多个状态的定义之后,就要开始围绕着组合造测试资料了——经典的组合测试会采用结对组合(在n = 2的情况下这就是全组合),就如上述引用的文章所说,这是性价比最高的方法,尤其是防止组合爆炸。在状态较少的情况下,其实止步于此就够了,很多状态组合导致的问题就能测出来了。但是,新问题来了,如果我的状态产出值是无法枚举的呢?比如时间、金额这种的。这个地方就该PBT出场了,这也是我最轻量引入PBT的地方。

直接用我们前面的组合构建出一个生成器——但新的问题又来了,那就是我很难从具体的数值上断言到底什么才是正确的,我无法「预言」结果,而PBT恰恰要求我有一个金标准判断结果。因此,这个地方我必须采用蜕变测试(Metamorphic testing),即在无法预测输出结果的情况下,通过断言关系,从结果是否满足关系的角度上来判断输出是否合法——而这东西,正好就是标准PBT最常用的断言种类之一。前置准备后,就可以开始大范围的打了。最终把结果抓出来,并通过 shrinking 收敛到范围内。

测试构建完成后,自然是进行回归。我把这一套新的输入侧补强引入了常规测试,并特别要求了一个无上下文的agent去跑整个测试,看能否在每次开发完都要进行的「例行测试」中复现我此前发现的问题(顺带一提,构建的过程中就出现了他把被测对象预设进去,将测试写成了回归,这是典型的先射箭后画靶,测不出实际结果,所以必须HITL去给予明确的反馈和监督)。结果是——成功抓到。从事后回测结果来看,这一套确实是有效的。

我觉得这次问题暴露最有效的价值体现在「流程」上。就像我之前在 基于过程的质量控制 中所说的一样——我个人的AI使用哲学始终是过程至上的。当代制造生产体系已经无数次的表明了流程对错误发现和修复的贡献,因此,相较于一次局部的问题,我会更关心「为什么我的流程没有发现它」来作为预警机制。一旦发现问题,除了针对于个别bug构建回归之外,对流程的重新检讨也是必要的一环。一次bug修复可以是现在的,但回归的价值就在于未来:完善的质量控制流程也是一样,不只是关乎现在,还同样关乎到未来。