Python单元测试的建议

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

(1) “工期紧没时间写测试”:写单元测试看上去要多花费时间,但其实会在未来节约你的时间。

(2) “模块复杂没法写测试”:也许这正代表了你的代码设计有问题,需要调整。

(3) “模块简单不需要测试”:是否应该写单元测试,和模块简单或复杂没有任何关系。在长期编写单元测试的过程中,我总结了几条相关建议,希望它们能帮你更好地理解单元测试。

一、 写单元测试不是浪费时间

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

但真的是这样吗?不写测试真能节省时间?我们看看下面这个场景。假设你在为某个博客项目开发一个新功能:支持在文章里插入图片。在花了一些时间写好功能代码后,由于这个项目没有任何单元测试,因此你在本地开发环境里简单测试了一下,确认功能正常后就提交了改动。一天后,这个功能上线了。但令人意外的是,功能发布以后,虽然文章里能正常插入图片,但系统后台开始接到大量用户反馈:所有人都没法上传用户头像了。仔细一查才发现,由于你开发新功能时调整了图像模块的某个API,而头像处理功能恰好使用了这个API,因此新功能妨害了八竿子打不着的头像上传功能。

如果有单元测试,上面这种事根本就不会发生。当测试覆盖了项目的大部分功能以后,每当你对代码做出任何调整,只要执行一遍所有的单元测试,绝大多数问题会自动浮出水面,许多隐蔽的bug根本不可能被发布出去。因此,虽然不写单元测试看上去节约了一丁点儿时间,但有问题的代码上线后,你会花费更多的时间去定位、去处理这个bug。缺少单元测试的帮助,你需要耐心找到改动可能会影响到的每个模块,手动验证它们是否正常工作。所有这些事所花费的时间,足够你写好几十遍单元测试。单元测试能节约时间的另一个场景,发生在项目需要重构时。假设你要对某个模块做大规模的重构,那么,这个模块是否有单元测试,对应的重构难度天差地别。对于没有任何单元测试的模块来说,重构是地狱难度。在这种环境下,每当你调整任何代码,都必须仔细找到模块的每一个被引用处,小心翼翼地手动测试每一个场景。稍有不慎,重构就会引入新bug,好心办坏事。而在有着完善单元测试的模块里,重构是件轻松惬意的事情。在重构时,可以按照任何你想要的方式随意调整和优化旧代码。每次调整后,只要重新运行一遍测试用例,几秒钟之内就能得到完善和准确的反馈。所以,写单元测试不是浪费时间,也不会降低开发效率。你在单元测试上花费的那点儿时间,会在未来的日子里为项目的所有参与者节约不计其数的时间。

二、不要总想着“补”测试

“先帮我review下刚提交的这个PR,功能已经全实现好了。单元测试我等会儿补上来!”在工作中,我常常会听到上面这句话。情况通常是,某人开发了一个或复杂或简单的功能,他在本地开发调试时,主要依靠手动测试,并没有同步编写功能的单元测试。但项目对单元测试又有要求。因此,为了让改动尽早进入代码审查阶段,他决定先提交已实现的功能代码,晚点儿再补上单元测试。在上面的场景里,单元测试被当成了一种验证正确性的事后工具,对开发功能代码没有任何影响,因此,人们总是可以在完成开发后补上测试。但事实是,单元测试不光能验证程序的正确性,还能极大地帮助你改进代码设计。但这种帮助有一个前提,那就是你必须在编写代码的同时编写单元测试。当开发功能与编写测试同步进行时,你会来回切换自己的角色,分别作为代码的设计者和使用者,不断从代码里找出问题,调整设计。经过多次调整与打磨后,你的代码会变得更好、更具扩展性。但是,当你已经开发完功能,准备“补”单元测试时,你的心态和所处环境已经完全不同了。假如这时你在写单元测试时遇到一些障碍,就会想尽办法将其移除,比如引入大量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的帮助找到了最适合自己的开发流程。

三、难测试的代码就是烂代码

在为代码编写单元测试时,我们常常会遇到一些特别棘手的情况。举个例子,当模块依赖了一个全局对象时,写单元测试就会变得很难。全局对象的基本特征决定了它在内存中永远只会存在一份。而在编写单元测试时,为了验证代码在不同场景下的行为,我们需要用到多份不同的全局对象。这时,全局对象的唯一性就会成为写测试最大的阻碍。再举一个例子,项目中有一个负责用户帖子的类UserPostService,它的功能非常复杂,初始化一个UserPostService对象,需要提供多达十几个依赖参数,比如用户对象、数据库连接对象、某外部服务的Client对象、Redis缓存池对象等。这时你会发现,很难给UserPostService编写单元测试,因为写测试的第一个步骤就会难倒你:创建不出一个有效的UserPostService对象。光是想办法搞定它所依赖的那些复杂参数,都要花费大半天的时间。

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

但有了单元测试后,情况就完全不同了。每当你写出难以测试的代码时,单元测试总会无差别地大声告诉你:“你写的代码太烂了!”不留半点情面。因此,每当你发现很难为代码编写测试时,就应该意识到代码设计可能存在问题,需要努力调整设计,让代码变得更容易测试。也许你应该直接删掉全局对象,仅在它被用到的那几个地方每次手动创建一个新对象。也许你应该把UserPostService类按照不同的抽象级别,拆分为许多个不同的小类,把依赖I/O的功能和纯粹的数据处理完全隔离开来。单元测试是评估代码质量的标尺。每当你写好一段代码,都能清楚地知道到底写得好还是坏,因为单元测试不会撒谎。

四、 像应用代码一样对待测试代码

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

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

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

最后一个区别,是对“重构”的态度。在写应用代码时,我们会定期回顾一些质量糟糕的模块,在必要时做一些重构工作加以改善。但是,我们很少对测试代码做同样的事情——除非某个旧测试用例突然坏掉了,否则我们绝不去动它。总体来说,在大部分人看来,测试代码更像是代码世界里的“二等公民”。人们很少关心测试代码的执行效率,也很少会想办法提升它的质量。但这样其实是不对的。如果对测试代码缺少必要的重视,那么它就会慢慢“腐烂”。当它最终变得不堪入目,执行耗时以小时计时,人们就会从心理上开始排斥编写测试,也不愿意执行测试。

所以,我建议你像对待应用代码一样对待测试代码。比如,你应该关心测试代码的质量,经常想着把如何把它写得更好。具体来说,你应该像学习项目Web开发框架一样,深入学习测试框架,而不只是每天重复使用测试框架最简单的功能。只有在了解工具后,你才能写出更好的测试代码。拿之前的pytest例子来说,假如你并不知道@pytest.mark.parametrize的存在,那就得重复写许多相似的测试用例代码。测试代码的执行效率同样十分重要。只有当整个单元测试总能在足够短的时间内执行完时,大家才会更愿意频繁地执行测试。在开发项目时,所有人能更快、更频繁地从测试中获得反馈,写代码的节奏才会变得更好。

五、避免教条主义

说起来很奇怪,在单元测试领域有非常多的理论与说法。人们总是乐于发表各种对单元测试的见解,在文章、演讲以及与同事的交谈中,你常常能听到下面这些话:· “只有TDD才是写单元测试的正确方式,其他都不行!”· “TDD已死,测试万岁!”· “单元测试应该纯粹,任何依赖都应该被mock掉!”· “mock是一种垃圾技术,mock越多,表示代码越烂!”· “只有项目测试覆盖率达到100%,才算是合格!”· ……

这些观点各自都有许多狂热的追随者,但我有个建议:你应该了解这些理论,越多越好,但是千万不要陷入教条主义。因为在现实世界里,每个人参与的项目千差万别,别人的理论不一定适用于你,如果盲目遵从,反而会给自己增加麻烦。拿是否应该隔离测试依赖来说,我参与过一个与Kubernetes[插图]有关的项目,项目里有一个核心模块,其主要职责是按规则组装好Kubernetes资源,然后利用Client模块将这些资源提交到Kubernetes集群中。要搭建一个完整的Kubernetes集群特别麻烦。因此,为了给这个模块编写单元测试,从理论上来说,我们需要实现一套假的Kubernetes Client对象(fakeimplementation)——它会提供一些接口,返回一些假 数据,但并不会访问真正的Kubernetes集群。用假对象替换原本的Client后,我们就可以完全mock掉Kubernetes依赖。但最后,项目其实并没有引入任何假Client对象。因为我们发现,如果使用Docker,我们其实能在3秒钟之内快速启动一套全新的Kubernetes apiserver服务。而对于单元测试来说,一个apiserver服务足够完成所有的测试用例,根本不需要其他Kubernetes组件。通过Docker来启动真正的依赖服务,我们不光节省了用来开发假对象的大量时间,并且在某种程度上,这样的测试方式其实更好,因为它会和真正的apiserver打交道,更接近项目运行的真实环境。也许有人会说:“你这么搞不对啊!单元测试就是要隔离依赖服务,单独测试每个函数(方法)单元!你说的这个根本不是单元测试,而是集成测试!”好吧,我承认这个指责听上去有一些道理。但首先,单元测试里的单元(unit)其实并不严格地指某个方法、函数,其实指的是软件模块的一个行为单元,或者说功能单元。其次,某个测试用例应该算作集成测试或单元测试,这真的重要吗?在我看来,所有的自动化测试只要能满足几条基本特征:快、用例间互相隔离、没有副作用,这样就够了。单元测试领域的理论确实很多,这刚好说明了一件事,那就是要做好单元测试真的很难。要更好地实践单元测试,你要做的第一件事就是抛弃教条主义,脚踏实地,不断寻求最合适当前项目的测试方案,这样才能最大地享受单元测试的好处。

原文作者:朱雷
转自链接:https://www.piglei.com/articles/5-tips-on-unit-testing/
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请保留以上作者信息和原文链接。

你可能感兴趣的:(单元测试)