单元测试更多是在"圈地盘",而非消灭 Bug
我是一名程序员,长期从事硬件相关工作。从这个角度来看,软件测试简直糟糕透顶——因为软件里到处都是漏洞。究其原因,无非是:人们总是催着程序员把带漏洞的代码上线,反正以后再修也不迟,于是程序里就一直堆满了等着日后修补的漏洞;而在硬件领域,漏洞往往(但并非总是!)会带来灾难性后果,所以他们别无选择,只能让你去修复,哪怕要花些时间。
从这个角度看,问题就在于:决策者认为漏洞不算什么,这种想法到底对不对?还是说他们自欺欺人,因漏洞而损失的钱其实比预防它们的成本还要高?
(我觉得有时候他们的判断是对的;而当他们错了的时候,除非拿出像硬件那样“一堆芯片被扔进垃圾堆”这样直观、形象的证据,或者有监管要求,否则很难说服他们。)
不过我们也可以换个角度来思考——软件到底是怎么测试的,又为什么要这么做?我的意思是,如果你根本不在乎漏洞,那完全可以不做任何测试,这在2010年代之前确实相当普遍。
可如今,单元测试却让代码量平均翻了一倍。这是怎么回事?这些测试到底为编写它们的程序员带来了什么?
单元测试在消除非 trivial 的漏洞方面作用有限,原因有两个:
- 许多棘手的漏洞和性能问题都源于各组件之间的交互,因此需要运行涉及多个团队所负责组件的“集成测试”才能发现这些问题——而像这类“非单元测试”一样,集成测试也极为罕见。事实上,人们甚至会不惜一切代价去“mock”自己的依赖,以避免触及“单元”之外的东西(比如只用抽象类来方便 mock,或者为每个测试都创建一个 API 的“桩”,这样一来,一旦要修改 API,不仅调用方要更新,所有这些桩也都得改,诸如此类)。
- 要找到那些隐蔽的漏洞,需要大量不同的输入组合,而单元测试的做法却是硬编码几组输入及其对应的输出。在硬件圈,人们通常会对“待测设计”生成随机输入,这会让你去思考输入的分布情况(不能让大部分输入都被代码迅速判定为无效),以及如何判断输出是否正确——而不是简单地运行代码,然后把它的输出硬编码为“正确”,这正是单元测试参考输出的典型来源。
既然单元测试在发现漏洞方面的效果远不如集成测试和/或输入随机器,那为什么它反而成了业界最普及的测试方法,而且还是在2010年代才兴起的呢?
我的答案是:单元测试的主要目的,是在一个一切都在不可控地变化的环境中,防止你的代码被他人肆意破坏。毕竟,“漏洞以后再修”这个问题,不过是“程序随时可以改”这一更普遍问题的一个特例——而硬件恰恰没有这个问题(当然,你也可以把代码易于修改视为一种优势,但从某种意义上说,它未必是,因为难的事容易,而易的事难)。
在这种人人都被告知要不断改动一切、并且逐渐形成一种“没有代码所有权”(所谓的“共享所有权”只是委婉说法)的文化氛围下,你怎么才能在最基本层面上阻止别人破坏你的代码呢?
“破坏”本身是一种社会建构;只有当我被迫去修复某处时,我才算是“破坏”了它——最好是在我提交更改之前就发现问题,因为天知道事后谁还会愿意去修东西。
单元测试正是应对这种疯狂局面的完美手段:
- 它们非常容易编写
- 运行成本低、速度快——因此可以在代码提交前设置为必过,几乎不会遇到阻力
- 它们仅以软件公司愿意为“质量”买单的程度,就能满足“质量”的标签需求
- 人人都可以随意改动一切——当然啦!——但前提是测试不能失败——这也理所当然!直接删除别人的测试,或者粗暴地修改它们以使其通过,甚至连作者都不打个招呼,这种做法未免太过荒谬;于是就有了这样一个机制:保护你的代码不被路人随手炸成碎片——现在他们要么让测试继续通过,要么就得跟你商量。
- ……而且大多数程序员都会热情支持这些测试,因为他们觉得写起来很简单,对自己也很有用——不是为了发现那些复杂的漏洞,而是为了确保自己放进去的东西在之后还能原封不动地待在那里——见鬼,就是这么回事。
这就是一种让管理层和程序员都满意的完美平衡,而且确实能让你走得更快——相比那种真的跑得飞快、却不得不拼命回退、人人试图弥补他人造成的损害的状态而言。
诚然,程序里既有各种奇怪的漏洞,也有不少浅显到单元测试根本覆盖不到的问题——但至少,这些部件大多还保留着当初开发者留下的痕迹,尽管团队规模庞大,改动频繁。
顺便一提,我认为这也是为什么测试会让代码量大约翻倍的原因——它们有点像把代码写了两遍:先是写出一段任人蹂躏的版本——共享所有权嘛——然后用测试再写一遍,告诉所有人:你们也不能完全把它毁掉,因为对他们来说,做不到这一点反倒更好。
你基本上是在重复自己,仿佛在说:“我第一次写的才是真的,拜托别把它撕得粉碎。”
(当你以找漏洞为目的而非单纯标记领地时,测试的长度可能比代码长得多,也可能短得多——这取决于具体情况;但领地标记的数量往往与领地的大小成正比。“这个函数还在,功能也差不多如预期;那个函数也还在……”)
为什么是2010年代?那正是分布式版本控制系统(DVCS)开始普及的时代,它们让整个代码库的分支与合并变得轻而易举——试想一下,如果没有配备单元测试的持续集成(CI),这样的系统用久了会把你的代码变成什么样(顺便说一句,CI 和 DVCS 大概也是在同一时期几乎普及的;有趣的是,DVCS 最早的高调应用是在 Linux 内核上,而当时的内核并没有单元测试,直到最近才开始引入;至于单元测试,在 DVCS 普及之前就已经在“敏捷”阵营中很流行了,后来随着 DVCS 的兴起,两者更是一起爆红)。
)
现在,如果从硬件工程师的角度来看,这一切仍然让人费解。硬件工程师大多对单元测试不屑一顾,因为他们觉得这对正确性帮助不大,于是会问:那你们为什么不再成立一个专门的验证团队,负责编写并运行集成测试和随机测试呢?
那样不仅能发现大量漏洞,而且有些漏洞若置之不理,其代价肯定比养这个团队还要高。
对此的回答是:兄弟,你觉得那会是个什么样子?首先,我们可不想让一个团队去给已经提交的代码报 bug——我们早就向前看了,明白吗?
你说要搞集成测试、随机测试——那他们该针对哪些 API 来做呢?既然我们的宗旨是随时改动一切,那是不是还得为这些人考虑 API 的稳定性(现实中,很多东西出于各种原因最终变得不可变,但这一点很少被承认)?
问题不只是你要我们花钱提升质量——你还打算拖慢我们的节奏,这可是绝对不允许的!
事实上,这正是软件质量的一条基本原则:只有当质量改进能够加快程序的变更速度时,才会被认为值得投入。任何试图以牺牲所谓的“速度”为代价来提升质量的人,都会以一种令人不快的方式亲身感受到这条原则的作用。
这也解释了为什么软件领域的主流测试方法与硬件领域大相径庭,以及为什么软件团队很少采用硬件界那些行之有效的做法——毕竟在硬件领域,关键在于消灭漏洞,而不是追求变更的速度。
感谢 Dan Luu——一位长期从事硬件工作的程序员——审阅了本文草稿,并多次指出软件测试有多糟糕,从而促使我写下这篇文章,最近的一次是在一篇展示智能体如何模仿人类程序员在这一领域的习惯的文章。
附注:强烈推荐随机测试
如果你在写一些复杂的代码,又希望它不出错,我真心建议使用随机测试——不一定非要 24/7 在多台机器上运行,只要执行几万次,或者按你的预算来决定次数——而且要用确定性的种子,这样你就可以把它纳入 CI,至少在这一组输入上防止其他人破坏你的代码。
(如果你的随机输入是真随机的,CI 就会变得不确定,人们只需反复重跑直到通过——这就是阻止人们在 CI 中运行 TSan的问题;真不知道为什么没人能让确定性的线程调度器配合 TSan 构建来缓解这个问题。)
我在“模块”级别进行随机测试——也就是几千到几万行代码——这样随机测试面对的是不易改变的稳定 API,而不是那些你负担不起投入这么多精力去维护的、时刻变动的内部接口。
编写这样的随机测试可能需要一周甚至更久,但一旦完成,你就能为所测代码带来接近硬件级别的可靠性。偶尔还可以让它连续运行一段时间,以便发现那些难以复现的漏洞,如果你是那种真正想主动修复漏洞的人(这种情况并不常见)。
我想强调的是:如果组织本身不想要,那么大规模的集成测试一个人是做不了的,而大多数情况下组织确实不想要;但即便别人都不这么做,一个人也完全可以善用随机测试,从中获益,而且对于某些类型的代码来说,这样做很有意义。
也许我会在未来的文章《DDT(开发驱动测试)》中进一步阐述这种方法。
再附注:智能体与单元测试
如今的智能体似乎更多是从人类的代码库中学到了东西,而不是通过某种强化学习过程学会了如何做好测试,结果它们用一些相当愚蠢的测试把代码量也拉高了差不多一倍。
在一个主要由智能体编辑的代码库里,这总比什么都不做强吧?说来也挺有意思,目前看来,我的答案是:是的。
智能体就像一个机灵、有经验的新员工,对你写的代码一无所知,刚刚被你“入职”。如果你所在的团队里大部分改动都是由这样的人完成,代码很快就会失去功能,也看不出任何结构可言。
由于智能体天生会尊重测试失败,这就相当于为你筑起一道防线,防止出现那种破坏性的改动——虽然挡不住复杂且不必要的结构被添加进来,而这些新增的部分也会被测试保护起来,免受日后的“重构”。
不过话说回来,人类程序员也会干这种事;在这里,以及许多其他场景中,智能体只是让你更快地到达那个地方——无论那里是你想要的结果,还是你最可怕的噩梦。
- 我觉得有一种说法是:之所以用“velocity”,是因为它是矢量,而“speed”是标量,我们关心方向之类的东西。不过我怀疑,我们之所以用“velocity”,纯粹是因为这个词更长,而我们就是喜欢长词,因为我们满嘴跑火车。