有关单元测试的 5 个建议

2021-04-22

虽然好像人人都认为单元测试很有用,但在实际工作中,有完善单元测试的项目仍然是个稀罕物。大家拒绝写单元测试的理由总是千奇百怪:“项目工期太紧,没时间写测试了,先这么用吧!”“这模块太复杂了,根本没法写测试啊!”“我提交的这个模块太简单了,看上去就不可能有 bug,写单元测试干嘛?”

这些理由乍听上去都有道理,但其实都不对,它们代表了人们对单元测试的一些常见误解。

  1. “工期紧没时间写测试”:写单元测试看上去要多花费时间,但其实会在未来节约你的时间;
  2. “模块复杂没法写测试”:也许这正代表了你的代码设计有问题,需要调整;
  3. “模块简单不需要测试”:是否应该写单元测试,和模块简单或复杂没有任何关系。

在长期编写单元测试的过程中,我总结了 5 个与单元测试有关的建议,希望它们能帮你更好的理解单元测试这回事。

1. 写单元测试不是浪费时间

对于从来没写过单元测试的人来说,他们对单元测试的看法往往是这样的:“写测试太浪费时间了,会降低我的开发效率。”从直觉上来看,这个说法似乎有一定道理,因为编写测试代码确实要花费额外的时间,如果不写测试,这个时间不就省出来了吗?

但真的是这样吗?不写测试真能节省时间?让我们看看下面这两个场景。

假设你在为某个博客项目开发一个新功能:支持在文章里插入图片。在花了一些时间写好功能代码后,由于这个项目没有任何单元测试,于是你在本地开发环境里,简单测试了一会,确认功能正常后就提交了改动。一天后,这个功能被发布到了线上。

但令人意外的是,功能发布以后,虽然文章里能正常插入图片,但系统后台却开始接到大量用户反馈:所有人都没法上传用户头像了。仔细一查后才发现,由于你开发新功能时,调整了图像模块的某个 API,而头像处理功能恰好使用了这个 API。因此,新功能最后弄坏了八竿子打不着的头像上传功能。

如果这个项目有单元测试的话,上面这种事儿根本就不会发生。当单元测试覆盖了项目的大部分功能以后,每当你对代码做出任何调整,只要跑一遍所有的单元测试,绝大多数问题都会自动浮出水面。许多隐蔽的 bug 根本不会被发布出去,因为单元测试会将它们扼杀在摇篮里。

因此,虽然不写单元测试看上去节约了一丁点时间,但有问题的代码上线后,你会花费更多的时间去定位、去处理这个 bug。缺少了单元测试的帮助,你需要耐心找到改动可能会影响到的每个模块,手动验证它们是否工作正常。所有这些事儿所花费的时间,足够你写好几十遍单元测试。

另一个单元测试能节约时间的场景,发生在项目需要重构时。

假设你要对某个模块做大规模的重构,那么,这个模块是否有单元测试,分别对应着两种天差地别的重构难度。对于没有任何单元测试的模块来说,重构是地狱难度。在这种环境下,每当你调整任何代码,你都必须仔细找到模块的每一个被引用处,小心翼翼的手动测试每一个场景。稍有不慎,重构就会引入新 Bug,好心就会办出坏事。

而在有着完善单元测试的模块里,重构是件轻松惬意的事情。在重构时,你可以按照任何你想要的方式,随意调整和优化旧代码。每次调整后,只要重新跑一遍测试用例,几秒钟之内你就能得到完善和准确的反馈。

所以,写单元测试不是浪费时间,也不会降低你的开发效率。你在单元测试上花费的那点时间,会在未来的日子里,为项目的所有参与者节约不计其数的时间。

2. 不要总想着“补”测试

“先帮我 Review 下刚提交的这个 PR,功能已经全实现好了。单元测试我等会再补上来!”

在工作中,我常常会听到上面这句话。情况通常是,某人开发了一个或复杂或简单的功能,他在本地开发调试时,主要依靠手动测试,并没有同步编写功能的单元测试。但项目对单元测试又有要求。因此,为了尽早进入 Review 阶段,他决定把已实现的功能代码先提交上去,晚点再补上单元测试。

在上面的场景里,单元测试被当成了一种验证正确性的事后工具,对开发功能代码没有任何影响,因此,人们总是可以在完成开发后补测试。

但事实是,单元测试不光能验证程序的正确性,它还能极大的帮助你改进代码设计。但这种帮助有一个前提条件,那就是你必须在编写代码的同时,编写单元测试。当开发功能与编写测试同步进行时,你会来回切换自己的角色,分别作为代码的设计者和使用者,不断从代码里找出问题,调整设计。经历过多次调整与打磨后,你写出的代码会变得更好,更有扩展性。

但是,当你已经开发完功能,准备“补”单元测试时,你的心态和所处环境就已经完全不同了。假如这时,你在写单元测试时遇到了一些障碍,你会想尽各种办法来粗暴移除这些障碍,比如引入大量 Mock,或者只测好测的,不好测的干脆不测。在这种心态下,你最不想干的事,就是调整你的代码设计,让它变得更容易被测试。为什么?因为功能已经实现好了,再改来改去又得重新测,多麻烦呀!所以,不论最后的测试代码有多么别扭,只要能跑起来就好。

测试代码并不比普通代码地位低,选择事后补测试,你其实白白丢掉了用测试来驱动代码设计的机会。只有在编写代码时同步编写单元测试,你才能更好的发挥出单元测试的能力。

我应该使用 TDD(测试驱动开发)吗?

TDD(测试驱动开发 Test-Driven Development 的首字母缩写)是由 Kent Beck 提出的一种软件开发方式。在 TDD 工作流下,要对软件做一个改动,你不会去直接修改代码,而是会先写出这个改动所需要的测试用例。

TDD 的大致工作流如下:

  1. 写测试用例(哪怕测试用例引用的模块根本不存在)
  2. 执行测试用例,让其失败
  3. 编写最简单的代码(此时只关心实现功能,不关心代码整洁度)
  4. 执行测试用例,让测试通过
  5. 重构代码,删除重复,让代码变的更整洁
  6. 执行测试用例,验证重构
  7. 重复整个过程

在我看来,TDD 是一套行之有效的工作方式,它很好的发挥出了单元测试驱动设计的能力,的确能帮助你写出更好的代码。

但在实际工作中,我其实很少宣称自己在实践 TDD。因为在开发时,我基本不会严格遵循上面的 TDD 标准流程。比如有时,我会直接跳过 TDD 的前两个步骤,不先写任何会失败的测试用例,直接就开始编写功能代码。

假如你从来没试过 TDD,我建议你可以了解一下 TDD 的基本概念,试着在项目中用 TDD 流程写几天代码。也许到最后,你会像我一样,并不会成为一名 TDD 的忠实信徒。但没准通过 TDD 的帮助,你能找到那个最适合你自己的开发流程。

3. 难测试的代码就是烂代码

在为代码编写单元测试时,我们常常会遇到一些特别棘手的情况。

举个例子,当模块依赖了一个全局对象(global object)时,写单元测试就会变得很难。全局对象的基本特征,决定了它在内存中永远只会存在一份。而在编写单元测试时,为了测试代码在不同场景下的行为,我们一定会需要用到多份不同的全局对象。这时,全局对象的唯一性就会成为写测试最大的阻碍。

再举一个例子,项目中有一个负责用户帖子的类 UserPostService,它的功能非常复杂,初始化一个 UserPostService 对象,需要提供多达十几个依赖参数。比如用户对象、数据库连接对象、某外部服务的 Client 对象、Redis 缓存池对象等等。

这时,你会发现,你很难给 UserPostService 编写单元测试,因为写测试的第一个步骤就会难倒你:你创建不出一个有效的 UserPostService 对象。光是想办法搞定它所依赖的那些复杂参数,都要花费你大半天的时间。

所以我的结论很简单:难测试的代码就是烂代码。

在不写单元测试时,烂代码就已经是烂代码了,只是我们并不能很好的意识到这一点。也许在 Code Review 阶段,某个经验丰富的同事会在 Review 评论里,友善而委婉的提道:“我感觉 UserPostService 类好像有点复杂?要不要考虑拆分下?”但也许他也不能准确的说出拆分的深层理由,也许经过妥协后,这堆复杂的代码最终就这么上线了。

但有了单元测试后,情况就完全不同了。每当你写出难以测试的代码时,单元测试总会无差别的大声告诉你:“你写的代码太烂了!”不留不点情面。

因此,每当你发现很难为代码编写测试时,你就应该意识到代码设计可能存在问题,你需要努力调整设计,让代码变得更容易被测试。也许你应该直接删掉全局对象,仅在它被用到的那么几个地方,每次都手动创建一个新对象。也许你应该把 UserPostService 类,按照不同的抽象级别,拆分为许多个不同小类,把依赖 IO 的功能和纯粹的数据处理完全的隔离开来。

单元测试给了你一个评估代码质量的标尺。每当你写好一段代码时,你都能清楚知道代码到底写的是好还是坏,因为单元测试不会撒谎。

4. 像应用代码一样对待测试代码

随着项目的不断发展,应用代码一定会越来越多,测试代码也同样会随之增长。在看过许许多多的应用代码与测试代码后,我发现,人们在对待这两类代码的态度上,常常有着一些微妙的区别。

第一个区别,是对重复代码的容忍程度。举个例子,假如在应用代码里,你提交了 10 行非常相似的重复代码。那么这些重复代码,几乎一定会在 Code Review 阶段,被其他同事作为烂代码指出来,最后它们非得被抽象成函数不可。但在测试代码里,10 行重复代码是件稀松平常的事情,人们甚至能容忍更长的重复代码段。

另一个区别,是对代码执行效率的重视程度。在编写应用代码时,我们非常关心代码的执行效率。假如某个核心 API 的耗时,突然从 100 毫秒变成了 130 毫秒,会是个严重的问题,需要尽快被解决。但是,假如有人在测试代码里,偶然引入了一个效率低下的 fixture,导致整套测试的执行耗时突然变慢了 30%,似乎也不是什么大事儿,极少会有人关心。

最后一个区别,是对于“重构”的态度。在写应用代码时,我们常常会定期回顾一些质量糟糕的模块,在必要时做一些改善质量的重构工作。但是,我们却很少对测试代码做同样的事情——除非某个旧测试用例突然坏掉了,否则我们绝不去动它。

总体来说,在大部分人看来,测试代码更像是代码世界里的“二等公民”。人们很少关心测试代码的执行效率,也很少会想办法提升测试代码的质量。

但这样其实是不对的。如果人们对测试代码缺少必要的重视,那么测试代码就会慢慢腐烂。当项目的测试代码最终变得不堪入目,执行耗时以小时为单位计算时,人们从心理上就会开始排斥编写测试,也不愿意去执行测试。

所以,我建议你应该像对待应用代码一样,来对待测试代码。

比如,你应该关心测试代码的质量,经常想着把如何把测试代码写得更好。具体来说,你应该像学习项目 Web 框架一样,深入学习测试框架,而不只是每天重复使用测试框架最简单的功能。只有在了解工具后,你才能写出更好的测试代码。拿 Python 的测试框架 pytest 来说,假如你并不知道参数化测试 @pytest.mark.parametrize 的存在,那你就得重复许多相似的测试用例代码。

测试代码的执行效率同样也十分重要。只有当整套单元测试,总能在足够短的时间内执行完时,大家才会更愿意频繁的执行测试。在开发项目时,所有人能更快、更频繁的从测试中获得反馈,写代码的节奏才会变得更好。

总结一下,在项目开发的过程中,除了关注应用代码的质量与效率以外,你也应该对测试代码一视同仁,只有这样做,才能最大发挥出测试的能力,让项目保持活力。

5. 避免教条主义

说起来很奇怪,在单元测试领域,长期有着非常多的理论与说法。人们总是乐于发表各种对单元测试的见解,在文章、演讲以及与同事的交谈中,你常常能听到下面这些话:

  • “只有 TDD 才是写单测的正确方式,其他都不行!”
  • “TDD 已死,测试万岁!”
  • “单元测试应该纯粹,任何依赖都应该被 Mock 掉!”
  • “Mock 是一种垃圾技术,Mock 越多,表示你的代码越烂!”
  • “只有项目达到 100% 测试覆盖率,才算是合格!”

人们乐于不断提出理论,也喜欢坚定不移的支持它们。但我的建议是:你应该了解这些理论,越多越好,但是千万不要陷入教条主义。因为在现实世界里,每人参与的项目千差万别,别人的理论不一定就适用于你,如果对任何理论盲目遵守,反而会给自己增加麻烦。

拿是否应该隔离测试依赖来说。我曾经参与过一个与 Kubernetes[1] 有关的项目,项目里有一个核心模块,主要职责是按规则组装好 Kubernetes 资源,然后利用 Client 模块将这些资源提交到 Kubernetes 集群中。

要搭建一套完整的 Kubernetes 集群特别麻烦。因此,为了给这个模块编写单元测试,从理论上来说,我们需要实现一套假的 Kubernetes Client 对象(fake implementation)——它会提供一些接口,返回一些假数据,但并不会访问真正的 Kubernetes 集群。用假对象来替换原本的 Client 后,我们就可以完全 Mock 掉 Kubernetes 依赖。

但最后,项目其实并没有引入任何的假 Client 对象。因为我们发现,如果使用 Docker,我们其实能在 3 秒钟之内,快速启动一套全新的 Kubernetes apiserver 服务。而对于单元测试来说,一个 apiserver 服务足够完成所有的测试用例,根本不需要其他 Kubernetes 组件。

通过用 Docker 来启动真正的依赖服务,我们不光节省了用来开发假对象的大量时间,并且从某种程度上,这样的测试方式其实更好,因为它会和真正的 apiserver 打交道,更接近项目运行的真实环境。

也许这时有人会说:“你这么搞不对啊!单元测试就是要隔离依赖服务,单独测试每个函数(方法)单元!你说的这个根本不是单元测试,你这个是集成测试(integration test)!”

好吧,我承认这个指责看上去有一些道理。但首先,单元测试里的单元(Unit),其实并不严格的指某个方法、函数,单元其实指的是软件模块一个行为单元,或者说功能单元。其次,某个测试用例应该被算做集成测试或单元测试,这真的重要吗?在我看来,所有的自动化测试只要能满足几条基本特征:快、用例间互相隔离、没有副作用,这就够了。

单元测试领域的理论确实很多,但这刚好说明了一件事,那就是要做好单元测试真的很难。要更好的实践单元测试,你要做的第一件事就是抛弃教条主义,脚踏实地,不断去寻求最合适当前项目的测试方案,那样才能最大的享受到单元测试的好处。

注解

  • [1] Kubernetes:一个流行的容器编排框架