没人写的回归测试:记录软件真实行为

内容管家 AI领域评论0字数 2566阅读8分33秒阅读模式

回归测试的盲区:那些没人写下、却被依赖的行为

一个运行了几年的代码库,总会留下这么一道尴尬的考题:你的测试描述的是系统实际在做什么,还是某个人曾经以为它应该做什么?

这两件事完全不是一回事,而且通常差得很远。大多数回归测试是功能开发时顺手写下的,作者记录的是当时的意图:这个函数返回那个值,这个接口会拒绝非法输入。它们本身没问题,但系统上线几年后会多出一整层没人规划、也没人记录的行为:各种怪癖、刁钻的边界情况,以及某个开发者在 2019 年随手定下的默认值——理由早已失传。而你大概可以下注:你的部分用户,或团队里其他某个服务,正悄悄地依赖着其中每一条。

最让人头疼的回归问题往往就藏在这里。而有意思的是,正好有一种测试是为这个缺口而生的——只是几乎没人写。

Google 的 Hyrum Wright 提出了海拉姆定律(Hyrum's Law),大意是:一旦足够多的人使用你的 API,系统中每一种可观测到的行为最终都会被人依赖。不只是文档里写明的契约,而是全部——结果返回的顺序、错误信息的具体措辞、一次调用耗时多久、空字段是显示为 null 还是干脆不显示。

维护过热门项目的人多半都经历过:你修掉一个明显错误,上线,自我感觉良好,不到一小时就收到 bug 报告,说某个依赖错误行为的集成挂了。对方有错吗?也未必。

常规回归测试在这里帮不上太多忙。它们只检查有人想到要检查的部分,所有"意外"的行为都会畅通无阻地漏过去。

核心变化:用特征化测试锁住现状

Michael Feathers 在《Working Effectively with Legacy Code》中给这类测试起了名字:特征化测试(characterization test)。思路几乎有点淘气——在动手改你还没完全理解的代码之前,先写测试把它当前的行为固定下来。不是它应该做什么,而是它实际做了什么。

跑一遍代码,看输出是什么,把这个结果钉进测试里。看着像 bug?那也照钉,最多给自己留条注释说明。这里追求的不是"正确",而是一张安全网:行为一旦偏移,立刻大声报警。

这本质上是一次思维翻转。普通测试说的是"这是我想要的,代码请照做";特征化测试说的是"这是现实中的样子,你要改它,最好有充分理由"。

为什么几乎没人写

主要是人的原因。

写起来枯燥。要刻画一个难缠的函数,就得喂给它一堆输入,并仔细记录每一个输出;要刻画整个服务?那意味着对每个接口、每个刁钻分支、每次与下游系统的交互都做一遍。谁有空干这个?

而且写起来"感觉不对"。断言一个你非常确定是 bug 的行为,违背了开发者的全部直觉。要真正接受"把 bug 钉住本身就是目的",需要一次观念转变:你不是在说它没问题,而是确保它一旦变化,一定是有人主动决定改的。

金标准与快照测试降低门槛

多年来大家一直在为这份枯燥买单。Golden master 测试(也称 approval 测试)思路是:一次性抓取一大块输出,存为"受认可的版本",之后每次运行都拿它比对。前端快照测试遵循同样的本能。两者都让特征化测试便宜得多——因为你是整块捕获输出,而不是逐个字段去断言。

它们在可以直接调用的对象上尤其好用:函数、报表生成器、渲染组件。服务就没那么好搞了。服务的行为不只是"返回什么",还包括它对真实请求如何反应、沿途做了哪些事:执行了哪些查询、调用了哪些其他服务、发出了哪些消息。

落地方案:用真实流量刻画整个服务

如果你把服务跑在生产环境,这里就有意思了。记录服务真实行为最好的载体,并不在任何测试文件里,而在流量里。

每一个真实的请求与响应,本质上就是一个还没被写下来的特征化测试。记录进出服务的数据,你就把它面对真实输入(包括那些没人会手工想出来的古怪输入)的行为钉住了。把它发往各依赖的调用也一并记录,连它的副作用一起固定。

在网络层面录制,就能不动应用代码地实现这件事,同时覆盖数据库与消息队列流量,不只是 HTTP。之后用这份录制回放,并让依赖的响应同样来自这份捕获,你就得到一个每次运行表现一致的特征化测试套件:相同输入进去,相同的下游行为。如果响应变了,或者服务突然多发了一次数据库调用,差异就会浮出来。

最后这一点被严重低估了。一些最昂贵的回归,响应完全不变,却悄悄改变了底下的行为——比如一条在测试环境无害、到了规模化就是灾难的新查询。基于真实流量构建的套件同样能抓到这类问题。

影响与建议:与特征化测试共处

它们的失败方式跟普通测试不同,这点需要适应。

一条失败的特征化测试不代表"有 bug",只代表"行为变了"。而紧随其后的追问永远是:我们是有意为之吗?有时答案是"是"——你故意修掉了某个怪癖,通知了依赖方,新行为成为新的基线。有时答案是"不是",恭喜,你抓到了一个任何基于意图的测试都不可能察觉的回归。

两种情况都比事后从用户那里听说要好。在 Pull Request 中认真审阅这类差异的团队,往往会养成一个非常健康的习惯:行为变更不再是意外,而成为决策。差异本身变成了一句简短的对话——我们真的想要这个改动吗?

噪声需要单独处理。时间戳和自动生成的 ID 每次运行都会变,放任不管就会淹没真正的信号。忽略这些特定字段、且只忽略这些,套件才能保持诚实——既不放过真实变化,也不因为无关抖动而误报。

对团队来说,特征化测试不是要取代现有测试,而是补上最后一块:从"验证我们以为对的东西"补到"保护我们没写下来、但已经有人在依赖的东西"。

前面我们讨论了"用录制的真实流量当作行为基线"这一做法,接下来看它在实际工程里最容易派上用场的几个场景。共同点是:这些场景里,测试要保护的从来不是某个函数应该怎么写,而是系统实际对外表现成什么样。

核心变化:基线保护的是"行为规格",不是代码结构

传统回归测试锁住的是断言和实现细节;而行为基线锁住的是输入输出本身。一旦有了基线,"改完之后跑一遍同样的请求、逐条比对差异"就成了一种可执行的验收方式——判断标准从"代码是否还长这样"变成了"对外行为是否变了"。

影响与建议:四类高价值场景

1. 重构遗留代码

这是 Feathers 当初写这类测试的初衷,也是最经典的场景。先把当前行为钉住,再放心地挪动结构,不用再每次改动都提心吊胆。

2. 框架与依赖库升级

Web 框架、ORM 换大版本时,行为常常会发生细微偏移——比如某个对象的序列化方式变了,或者查询结果返回的顺序不同。这类变化是"意图导向的测试"往往看不到的,而基线对比能直接把它们暴露出来。

3. 服务重写 / 跨语言迁移

这可能是最理想的应用场景。旧服务录下来的真实流量,本身就成了新服务的规格说明:对新旧两个版本发同样的请求,任何差异要么是有意为之的改进,要么就是回归缺陷。省去了"旧系统本来应该是什么样"的反复争论。

4. AI 辅助的代码改动

这类场景正在变得越来越普遍。当助手一次性完成大规模重构并顺手更新了测试时,问题就来了:新测试往往和这次改动共享同一套假设。先前捕获的基线,是极少数不依赖改动方自身假设的验证手段。

落地步骤

  1. 挑一个你"有点不敢动"的服务。
  2. 在下次重构之前,先录下它当前真实的请求与响应,作为基线。
  3. 改动落地后,用相同的请求集跑一遍新版本。
  4. 逐条审核&查验差异,确认每一处差异都是有意为之。

我的经验是,几乎每次都能翻出一两个你事先并不知道的既有行为。在 diff 里遇到它们,好过在 bug report 里遇到。

延伸阅读

 
内容管家

发表评论