XML 之父 Tim Bray 在日前发表的一篇博文中,基于自身的多年软件开发经验,分享了关于软件测试的那些事。“我非常确信,在我有生之年,对软件发展的最大贡献不是来自面向对象方法和高级语言、函数式编程、强类型、MVC 或其他任何东西,而是来自测试文化的兴起。”
成熟的软件开发人员非常清楚测试的重要性。但是从我的经验来看,很多人在这方面做得还不够。所以我写下这篇文章的目的是要警醒行业,或许这对于我们的从业人员来说本是多此一举,但现实显然不是这样。
本文的灵感来自于 Justin Searls 的两个 Twitter 帖子,这里引据其中的几句话:“你听到的几乎所有关于软件测试的建议都是糟糕的。这些建议要么看上去就很糟糕,要么会导致糟糕的结果,要么会让你专注于错误的事情(通常是工具),结果分散了注意力”,“几乎没有团队会编写富有表现力的测试、建立清晰的界限、快速可靠地运行,并且只会因有用的原因失败。大家的重点都错了。”(注意:Justin 显然身处测试行业。)
Twitter 帖子都弯弯绕绕、很难摸清脉络,所以我从中贴两张截图出来。
我先亮明我的观点:我认为这些不规则的所谓的“结构图”是大错特错,而且影响严重。
自 1979 年以来,我一直从事软件开发工作。虽然我的观点很可能是错的,但这并不是因为我缺乏经验。我做过的几乎所有有意义的工作都是底层基础设施的东西:解析器、消息路由器、数据可视化框架、网络爬虫、全文搜索。所以如果你不在基础设施领域,我的一些发现可能就不那么有说服力了。
在我编程生涯的前 20 年,比如说直到 2000 年,行业内几乎没有软件测试的位置。一个后果是,如同 Gerald Weinberg 经常被引用的一句话:“如果建筑师按照程序员编写程序的方式建造建筑物,那么飞来的第一只啄木鸟就会摧毁整个文明。”
那时,对于自己写的任何软件,我总会在几年后开始讨厌它,因为它变得越来越脆弱和可怕。现在回头想想,我抗拒的大概是那些未经测试的代码给人带来的体验,因为经常会有一些小更改由于难以理解的原因意外而引发“大灾难”。
2000 至 2010 年的某个时候,情况开始发生变化。我的看法是,最初的推动力或多或少来自 Ruby 社区,并随着 Rails 的兴起而加速。我开始听到“测试的感染”(test-infected)这个词,我注意到如果提交的代码没有像样的单元测试,它们很容易被无情拒绝。
其他人告诉我,他们最初被围绕 Martin Fowler 的《重构》一书中的讨论打动,这本书最早出版于 1999 年,告诉大家你无法真正重构未经测试的代码。
特别是我记得自己在 2010 年参加了苏格兰 Ruby 技术大会,会上似乎有一半的演讲是关于测试最佳实践和技术的。我在那里学到了很多我今天仍在应用的知识。
我非常确信,在我有生之年,对软件发展的最大贡献不是来自面向对象方法和高级语言、函数式编程、强类型、MVC 或其他任何东西 ,而是来自测试文化的兴起。
我们现在做事的方式好得多了。用回前面建筑师和程序员的比喻来说,文明不用再害怕啄木鸟了。
例如:我在谷歌和 AWS 工作的那些年里,我们遇到过很多中断和故障,但很少是因为某个软件错误这样简单的原因造成的。拙劣的部署、造成障碍的错误配置、证书问题(真是让人头疼)、DNS 小问题、实习生使用 Python 脚本做负载测试、金丝雀故障……痛苦的记忆来自很多因素,但往往不会仅因为一个小错误。
我不记得我是什么时候被“感染”的,但我可以保证,一旦你被感染,你永远不会容忍未经测试的代码了。
是的,你可以在上过公共厕所后不洗手;是的,你可以用手指吃意大利面,但负责任的成年人不会做这些事情,他们也不会交付未经测试的代码。顺便说一句,我后来也不再讨厌我开发了一段时间的软件了。
随着时间的流逝,我对糟糕测试的容忍度越来越低。我阻止别人升职、给别人打低分、斥责高级开发经理,而且一般都没得商量。我可以不树敌,可以容忍(大多数)情况,因为我尊重他人、待人友善和富有同情心。但在这个问题上我不会后退。
所以,我至死都会坚持这项原则(呃,我想应该是一系列原则):
现在我将扩展上述列表中的声明。其中一些不需要进一步的拓展(例如“单元测试应该运行得很快”)。
但首先… 你能证明测试的有效性吗?
哦,不能。我四处寻找有关测试效果的高质量研究,但没有找到什么结果。这并不令人惊讶。因为你需要找到两个强大的团队来执行重要的开发任务,并且在规模、结构、工具、技能水平和工作实践——在除测试之外的所有方面的表现都大致相同。然后,还需要在十年或更长的周期内研究他们的生产力和质量差异。据我所知,从来没有人这样做过,对这一结论我是很有信心。所以我们只剩下了经验积累,Nero Wolfe 称之为“经验总结出来的智慧”。
当你创建一个新特性并实现一系列函数来完成它时,不要自欺欺人地认为你足够聪明,提前知道哪些东西容易出错,哪些将成为瓶颈,哪些将是你的继任者难以理解的。毕竟没有人足够聪明!因此,只要不是单行代码的内容都要编写测试。
上面那张 Spotify 的图中“实现细节”的标签是反对单元测试的,这让我很不爽。我在这里嗅到了不接地气的架构师的味道,这些人认为所有的工作就是在白板上正确地放置方框和箭头,而不是亲手去写分号和 if 语句。如果你的基础微服务代码没有经过充分测试,那么你就是在聚沙成塔而已。
在经过良好单元测试的代码库中工作会给开发人员带来勇气。如果重新实现一两个 API 会带来一些小的改变,那么你可以大胆一点去做。因为有了良好的单元测试,即使你搞砸了,你也会很快就发现问题。
请记住,代码的读取和更新频率高于编写频率。我个人认为,好的测试在第一次开发过程中就可以帮上开发人员的忙,并且不会减慢他们的速度。就我对这一职业的了解,单元测试为将要学习和修改这些代码的后续开发人员们,带来了显著的生产力提升并减轻了他们的痛苦。这就是业务价值所在!
那我们是否可以在哪里放宽单元测试覆盖率呢?比如早在 2012 年,我就写过关于测试 UI 代码的文章,尤其是关于移动 UI 代码。给它们编写测试太难了,在某些情况下可能不是一项好的投资。
另一个例子是 Java 世界专属的,存在依赖注入框架的情况下,你会得到非常大的文件,其中包含数以千计的配置乱码[*cough*SpringBoot*cough*],生命有限,实在没时间研究它们。
还有一些非常罕见的异常处理场景,你的数据中心很可能在遇到它们之前就陷入了困境,此时 IOException 会是你手头的那堆麻烦里最不起眼的,所以,也许我们不应该沉迷于那些 if err != nil 子句。
我并不会强求代码库应该达到某个覆盖率。但是这些数据其实很有用,你应该注意下它。
首先,找到特殊情况——覆盖率明显过低(或高)的文件,然后查找签入之间的更改。
覆盖率数据不仅仅是一个百分比数字。当我基本完成某段代码时,我喜欢运行一个测试,开启覆盖,然后快速浏览所有重要的代码块,查看绿色和红色的侧栏。每次这样做我都会得到惊喜:往往在有些文件上我本以为我的单元测试很聪明,但覆盖率实际上差了很多。这不仅让我想要改进测试,它还教会了我一些我原不知道的关于我的代码如何对输入做出反应的知识。
话虽如此,我非常尊重一些软件团队,他们有严格的覆盖率要求并能坚持下去。AWS 有一个团队,在他们的 CI/CD 管道中实际上有一个 100% 覆盖率的 blocking 检查。我不确定这是否合理,但这些人正在基础设施的关键部分编写非常底层的代码,在这种情况下不合理的东西可能也是合理的。而且他们比我聪明。
另外,我经历过的所有团队工作,都会遇到一些测试不足、拖累工作的遗留代码。即使是像我这样的测试狂也不会要求别人将高覆盖率单元测试改装到那些破东西上。
我见过一项很成功的策略,它有两个部分:首先,当你对没有单元测试的函数进行任何重大更改时,请编写单元测试。其次,导致覆盖率下降的签入是不允许的。
这很有效,因为当你在应对大型老旧代码库时,更新通常不会均匀地分散在其中,代码库中会有一些有用行为聚集的热点。所以如果你应用这个策略,代码“热区”的测试覆盖率会有机增长,达到相当不错的水平,而其他代码可能多年没有人接触或查看过,它们被忽略掉也没关系。
测试应该是最务实的活动,没有意识形态介入的余地。
请不要跟我扯什么 mock、stub、fake,没人在乎。在一个相关主题上,当我发现很多人在针对 DynamoDB 运行代码的单元测试中使用 DynamoDB Local 时,我感到非常惊讶。但它真的很有效,速度很快,而且比编写另一个 mock 或设置一个到实际云服务的链接要省事很多。不要教条主义!
然后来谈 TDD/BDD 信仰。有时,对于某些人来说,它很有用,给他们带来了更多力量。但对我来说它的纯粹形式从来没什么用,因为我的编码风格在早期阶段往往是混乱的,我一直在不断地重构和重构函数。如果我在开始编写它们之前就知道我想让它们做什么,那么 TDD 可能是有意义的。另一方面,当我已经草拟了一组我认为合理的方法,并且正在为基本代码编写测试时,我会提前准备并为还没写出来的代码编写更多测试。我这个样子没法成为 TDD“教会成员”,但我不在乎。
还有另一种信仰:在 Java 中对私有方法进行单元测试并不容易,Java 错了。有些人声称你不应该测试这些方法,因为它们不是类合约的一部分,那些人也错了。为了方便测试,妥协封装并让方法非私有是完全合理的。或者出于同样的原因应该编写 API 来获取接口,而非类对象。
当你针对复杂的 API 运行大量测试时,很容易就可以编写一个 runTest() 帮助程序,将正确布置参数并针对结果运行标准化检查。如果你不这样做,你最终会得到很多重复的剪切粘贴代码。
这里有争论的余地,没有教条的空间,我通常对此不大同意,因为当我更改了某些东西、并且是我以前从未见过的单元测试失败时,我不想在弄清楚发生了什么之前还得去搞清楚一堆帮助程序。
无论如何,如果你的工程师正在编写带有有效测试的代码,请不要跟他们讲任何废话。
有一次一个同事来找我寻求帮助,查看之后发现他们的问题很棘手。然后我让他们向我展示代码库,我提出了一些审查请求。
我看的前几段代码没有单元测试,但确实有注释说“稍后进行单元测试”。我走进他们的团队房间说:“伙计们,我们现在需要谈谈。”
在这里我想强调的是:单元测试一旦推后,就不会有人做了!
以及重点是,代码审查的目的不是正确性检查。审查人有权假设代码是有效的。审查人应该检查 O(N3) 瓶颈、可读性问题、笨拙的函数参数、不稳定的错误处理等。如果你没有足够的测试来证明你的代码的基本正确性,那么要求审查人考虑这些事情是不公平的。
进一步地说,我在审查时经常会遇到这样的情况,我很难弄清楚开发人员到底想在某段代码中完成什么。但首先,我会转到单元测试并查看它在做什么,因为有时从这里就可以明显看出开发人员所设想的函数用途。这也适用于需要修改代码的后续开发人员。
做出本文前面所展示的图片的人似乎都认为这很重要,当然他们是对的。不过,我不确定“集成”和“端到端”之间的区别是否那么重要。
问题是我们在从单体迁移到微服务,于是这些测试变得更加重要,但也让它们更难构建。如果可以,这是坚持使用简单的单体应用的另一个很好的理由。
这反过来意味着你必须确保为你的集成测试规划时间,包括设计和维护时间。(单元测试只是基本编程预算的一部分。)
我知道这些测试很难写,我曾与其他优秀的团队一起工作,但他们的集成测试都很糟糕。
不好的一面是它们需要跑几个小时,这个就没什么好说的,因为时间目标经常没法达成。我们这么说吧:集成测试不需要像单元测试一样快,但它们确实需要足够快,这样你就可以在去上厕所或喝咖啡,或被聊天窗口打断时运行它们了。不过这还是很难实现的目标。
最后,我一次又一次地看到集成测试日志显示很多失败,一些开发人员会说“哦,是的,那些测试是不稳定的,它们有时会失败。”出于某种原因,他们认为这是可以的。要么测试执行了一些可能在生产中失败的任务,在这种情况下你应该将失败视为 blocker,或者这些任务不会在生产中复现,在这种情况下你应该将它们从该死的测试套件中取出,然后测试就会运行得更快了。
因为我总是在处理对性能非常敏感的代码,所以我经常会编写基准测试,一段时间后我养成了将其中一些留在测试套件中的习惯。因为我已经观察到很多由性能下降引起的中断,比如某个配置改动将 TLS 计算从硬件推入 Java 字节码这样的蠢事。你真的会希望提前发现这种情况。
可用的工具有很多,足够用了。让你的团队就他们将要使用的内容达成一致,并成为相应的专家,然后不要把你的问题归咎于工具上。
我认为我们的整体情况还不错,因为大多数理智的组织都开始表现出相当好的测试纪律,尤其是在服务端代码方面。就像我说的,我在生产代码中看到的错误比以前少了很多。
而且每个团队都必须与那些可怕的、未经检验的、停滞不前的遗留代码池作斗争。打起精神来吧,处理它们只是工作的一部分。而且不管怎样,你可能也写过那样的代码。
但每天总有团队会迷失方向,“开始在上完厕所后不洗手”。不要这样做,并且不要发布未经测试的代码。
关于作者
Tim Bray,全名 Timothy William Bray,有“XML 之父”之称,XML 和 Atom 标准的创建者,曾先后就职于 DEC、Sun、Google 等公司。
原文链接:
https://www.tbray.org/ongoing/When/202x/2021/05/15/Testing-in-2021
领取专属 10元无门槛券
私享最新 技术干货