虽然好像人人都认为单元测试很有用,但在实际工作中,有完善单元测试的项目仍然是个稀罕物。大家拒绝写单元测试的理由总是千奇百怪:“项目工期太紧,没时间写测试了,先这么用吧!”“这模块太复杂了,根本没法写测试啊!”“我提交的这个模块太简单了,看上去就不可能有 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 的存在,那你就得重复许多相似的测试用例代码。
使用 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),其实并不严格的指某个方法、函数,单元其实指的是软件模块一个行为单元,或者说功能单元。其次,某个测试用例应该被算做集成测试或单元测试,这真的重要吗?在我看来,所有的自动化测试只要能满足几条基本特征:快、用例间互相隔离、没有副作用,这就够了。
单元测试领域的理论确实很多,但这刚好说明了一件事,那就是要做好单元测试真的很难。要更好的实践单元测试,你要做的第一件事就是抛弃教条主义,脚踏实地,不断去寻求最合适当前项目的测试方案,那样才能最大的享受到单元测试的好处。