资深开发关于单元测试的5条建议

再举一个例子,项目中有一个负责用户帖子的类 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条建议

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

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

5、避免教条主义

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

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

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

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

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

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

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

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

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

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

上一页12下一页


留言