单元测试在软件开发中的作用

正确理解 单元 测试

单元测试是测试的一个子类,并非写了测试就叫单元测试,甚至你用了单元测试框架也有可能写出越过单元测试边界的代码。正确的单元测试就是确保测试代码准确隔离(isolate)了待测代码,如果你测试一个类,那么测试代码中就应该避免出现对于其他类的依赖(语言的标准库或者框架提供的工具方法/助手方法例外),甚至你测试该类的某个方法都要尽量避免对类内部其他成员的依赖。

当然这在现实里是不可能的,100% 没有依赖没有耦合的代码是不存在的,即使存在也没啥实际用处。我们不可避免的要让代码彼此交互,这种交互也不可避免的要体现在测试代码中。后面我会讲到一些解决的办法,不过在最开始我需要强调单元测试的根本性质,这样你才不会误以为剩下的内容讲的是集成测试或者验收测试什么的。

再强调一次:单元测试的根本性质就是要正确隔离待测代码。如果这种说法很难理解,我试着举一个通俗的例子:

如果你要测试 一只狗是不是吃饱了,那么相应的单元测试里就不应该依赖 根据这只狗何时吃的上顿饭,吃的东西是什么,吃的量有多少,谁喂的这一顿……等等前置条件 才能做出断言,特别是当这些前置条件要依赖系统的其他组件才能产生的情况下就更要小心!即使该方法(比如说 a_dog.is_full)的返回结果的确要依赖前置条件才能正确输出,单元测试本身也 不应该浪费精力在塑造这些前置条件上,而是应该把重点放在 测试和保障该方法的返回结果是预期的并且在可预见的各种边缘条件下该方法的返回结果都不会超出预期 之上。至于各种前置条件(包括边缘条件),可以伪造(后面会讲)它们而不是去调用真正生成它们的其他代码,只有这样才能保证“隔离性”,才能称的上是单元测试

我见过同事埋冤甚至咒骂写单元测试这件事情,我其实很能理解他们的心情而且我也清楚症结在哪里(浪费太多精力在创造完成断言的前置条件上),其实就差这一层窗户纸,只要能理解“隔离”这两个字在单元测试中的意义就能捅破它。

TDD 的时机

TDD,即测试驱动开发,是一种利用测试受益的方法论(或者说实践准则)。简单地说,TDD 就是在写代码前先写测试,并严格遵循 red => green => refactor(错误 => 正确 => 重构)的流程,所以才叫做“测试驱动开发”。驱动 这两个字才是 TDD 的核心思想。

然而 TDD 并非完美无缺,很多高水平的程序员都对 TDD 颇有微词(非等级化歧视,只是因为富有经验者才容易体会到一些问题,他们大部分都是高水平程序员——除了我以外……),总的来说就是认为 TDD 会影响开发的效率甚至在某些极端情况下会阻碍开发的顺利进行。在这里我尽量不说关于 TDD 的坏话,而是说它能带来的帮助。

刚才提到“驱动”二字才是 TDD 的核心思想,因此若想避免 TDD 可能带来的问题,我们就要利用好 TDD 能够驱动开发的这个特性。那么开发者何时需要测试来驱动?我认为大致有两种情形是适合(甚至是必要)的:

  1. 准备编写自己觉得“没谱”的代码
    觉得“没谱”可能存在多种原因,比如说业务逻辑很复杂自己没完全吃透;比如说脑中大概有一个思路,但由于以前没写过所以吃不准是不是能行,也无法确定过程中是否会产生难以预料的变数等等。
    如果不存在测试这回事,你会如何应对上面的情形?好一点的可能会把思路整理一下写个步骤列表或者画个流程图什么的,比较糟糕的则是先动手写了再说,万一不行再改。这时候你应该尝试 TDD 了,但是到底怎样做呢?
    其实但凡是测试其基本原理都是一样的:你给测试用例一种输入,然后断言其结果,最后执行并观察断言是否正确;以此类推,你写 N 个测试用例,每一个都覆盖某种可能的输入(边界条件)并断言可能的结果(结果可能是返回值,也可能是某种行为所体现的特征),然后执行观察结果。如是而已。
    TDD 也是测试,所以不用想复杂了,它遵循一样的原理,只不过是在没有代码的前提下先写测试罢了。因此你甚至不需要把代码的整个处理过程理清楚,只需要想好边界条件有哪些(这是目标代码的输入或前置条件。另外,边界条件的意义就在于不需要穷举所有条件,只要能覆盖就足够),对应的结果应该是什么就可以写出足够的测试用例了。这有点像为大型的复杂架构设计接口,你不去考虑具体实现而是考虑输入输出,我喜欢称之为:思路黑盒化。之后就是运行代码看它失败,接着写代码让它成功,此时你有了可靠的测试用例于是可以立即着手优化或重构代码,直到最终交付。
    所有的测试都是如此,不是么?不过就 TDD 本身再强调两件事情:
    1. 从第一次测试失败到第一次测试成功,这个过程不应该是一步实现的(除非你的代码实在是太简单了,但这样的话也就没必要非 TDD 不可了)。每一次你写下代码,它们唯一的目的就是要解决上一次测试失败的原因,从而让测试产生新的(进一步的)失败,直到测试成功为止,这就是俗话说的“小步快速走”的测试策略,它的好处很多,比如说可以把你的思考总是保持在可控范围内,每一步都只需要解决简单的问题,最终解决一个复杂的问题;再比如说有助于你写出设计良好,更加健壮,更加易懂的代码等等。而且由于这个过程是反复的,因此重构的环节也不一定非要放到最后才开始,你完全可以在中间某处就开始重构。这就好像随着思路的逐渐清晰和明朗,你忽然意识到还有某种更好的方法可以利用,于是你不必非得后知后觉。
    2. 测试产生的失败分为两种,一种是代码抛出的异常(比如说类或方法不存在,某处写错了名字等等),另一种是断言的条件没有满足(其中也可能包括对于异常处理的断言哦,要注意区别)。严格来说,只有第二种才叫测试失败,第一种应该算是语言本身的正常反应(你写错了嘛),所以一定要注意区分二者。绝大部分情况下这两种情形的输出都是有显著差异的,所以应该不难辨别。千万别让自己因为语言抛出异常却不自知,反而使劲儿和测试代码硬磕,这种低级错误需要杜绝。
  2. 准备重构即有代码,有可能是改善,也有可能是添加新的功能特性或处理新条件的逻辑,再有就是修复 Bug 等,在这里我都笼统的归为重构
    TDD 可以说是为了重构而生,所以它随着敏捷化方法论而开始被人熟知也就一点也不奇怪了。重构代码时最大的困难就是你事先无法估量旧代码究竟有多复杂(包括预料之中的和预料之外的),因此你的每一点改动都可能引起无法预期的影响,“蝴蝶效应”这个词很准确的体现了这一问题。
    重构有规模上的区别,对于大规模的重构 TDD 也不能面面俱到,因为这超出了单元测试的能力范围。大规模的重构往往都需要自上而下,从外到内的来做,通常都是需要先从验收测试或集成测试开始,一点一点的深入底层和内核,直到把范围缩小到 TDD 能够覆盖的层面(比如具体到某个方法)。
    重构也有种类上的划分,有时候是为了优化算法,有时候是为了解决 Bug,有时候是为了增加功能……不同类型的重构中 TDD 扮演的角色也有区别。比如说优化算法的重构,写测试的重点在于覆盖边界条件,保证算法优化后不会遗漏原有的代码逻辑。而且这种重构往往还要附加性能测试才知道算法优化究竟有没有效果,这就需要 A/B 测试的介入了。解决 Bug 的重构,测试的重点在于重现 Bug。Bug 产生的原因往往很复杂,会牵涉到多个系统部件的协同工作,而单元测试的覆盖范围有限,所以得先用更高层级的测试手段了缩小可能引发 Bug 的范围。增加功能则涉及到代码设计,这种重构往往会将旧的代码进一步拆分组合,以更高的抽象层级来保障可扩展性和可重用性,此时测试的重点在于帮助你梳理出进一步抽象的思路——这其实比较接近写新的代码。
    为重构而应用 TDD,最值得注意的就是分解旧的代码,这是重构最常用的手法之一。当你拆分一个单元(比如一个方法)时,你得先确保有足够的单元测试来覆盖原来的代码逻辑,然后把复杂逻辑逐层拆分,每次拆分(往往会多出一个方法来)都应该先有测试用例来驱动分出来的代码,并且在测试的时候除了运行新的测试外,还要运行老的测试代码以确保拆分后不会影响原来的代码逻辑。这个过程很繁琐,容易产生疏漏,要学会善于利用自动化工具和提示工具的特性(比如说排除其他干扰测试用例之类的)。

补测试的建议

很多时候,项目的开发受限于时间或人力等资源没条件履行 TDD(或 BDD 等其他测试先行策略),等到后期有精力补测试的时候却觉得狗咬刺猬无处下“口”,这时候怎么办才好呢?其实最好的办法就是抓住每一次解决 Bug 的时机。

上文提到过,Bug 的出现是会很复杂的,单纯的单元测试往往会变成“拆了东墙补西墙”。如果你的项目到了补测试的阶段,最好由外向内,从上到下的来补(这和大规模重构的前提是一致的),为什么呢?

因为 Bug 通常都是用户在使用过程中反馈来的(我把测试人员也算在用户之中),用户接触不到更深更内在的结构,他们都是通过用户界面来感受到软件系统的问题的。此时最好的入手点当然就是重现 Bug,而我们刚说过单元测试覆盖不了上至用户界面的层级,所以重现 Bug 都是从验收测试开始的,由此入手一层层抽丝剥茧,经历集成测试最终定位到单元测试。这一趟下来不但 Bug 解决了,而且连带着把一批复杂的系统交互都用测试覆盖了,这样用不了多久你就会发现该补的测试也都补的差不多了。

很多人管这个叫:Bug 驱动开发。说起来其实也是 BDD(行为驱动开发,Behaviour Driven Development),只不过是特定的一类行为,即引发 Bug 的行为。

谁来编写什么样的测试

由补测试引发的下一个话题就是谁来编写什么样的测试。我知道大多数开发团队要么人力有限,要么水平有限,所以测试基本上还是开发人员自己来承包了。不过在这里我还是要讨论一下比较理想的情况,即不差人也不差水平的理想环境里。

验收测试:虽然不确定业界是否有相应的标准,但我眼中的验收测试(Acceptance Test)可以分为两类,一类是给最终客户编写的验收测试,通常由项目经理/产品经理来编写(当然可以有助理代劳),另一类则是有软件测试人员编写的,供团队内部验收使用——或许叫功能/特性测试(Feature Test)更合适。

第一类很罕见,我只在书上见过对其的阐述(自己写过玩儿的,但没法用于实践,太超乎客户的认知了)。此类测试使用某些非常接近自然语言的脚本语言来编写,看起来就和文本型的需求文档差不多,不懂编程的人也能够看得懂,而且它是能运行的,并且能持续集成,能生成报告文档。我认为这玩意儿用得好了,在那些很重形式的项目里可以很好的替代一些劳命伤财的文档编写(给甲方的文档),但对于编写者而言终究是有门槛的,所以罕见。

尽管此类测试也自称属于 BDD 范畴,但是对于绝大多数开发者而言,它和代码的距离(特指感官上的)有点远,所以即使要践行 BDD 开发者们也宁愿选择写起来更像代码的测试框架,于是此类测试的发展方向就朝着商用可执行测试文档的路子上去了。有兴趣的可以看看 Cucumber,是一种基于 Gherkin 语言的验收测试框架,支持多语言多平台,有付费的在线版可用。(学 Rails 的应该都听说过,早期就是在 Rails 接着 BDD 的名头搞得风生水起的,直到 RSpec 茁壮成长起来才“拨乱反正”)

第二类(为了区别第一类,下文用特性测试来代替)就有用的多了,作为开发工作最外层的测试环节,这种测试最好还是由测试人员编写。特性测试反映了软件系统最终面向用户的行为表现,它以自动化脚本的方式来取代传统的手工测试,基本原理就是配合一种浏览器驱动器(特指 Web 开发范畴,因为别的领域我不熟)来模拟用户交互行为从而测试软件的各种功能特性。也有不需要依赖 GUI 的驱动器(Headless Browser)用做持续集成,还有在线的跨平台跨浏览器兼容性测试服务等,这些工具/服务共同组成了特性测试的集团军。

特性测试也可以在代码实现之前就编写好,用以指导程序员的开发工作,保证他们在理解上不出现偏差,在实现上力求准确无误。对于测试人员来说,不必关心代码的具体实现,只要在功能发布测试后能跑通特性测试即可,在一定程度上省了测试开发互相推诿磨嘴皮子的烂事。特性测试写的好,产品经理/项目经理也就无需跟保姆似的盯着每个程序员去解释每个细节。

特性,或者说功能,体现的是代码最终的行为。特性测试先于代码实现并指导代码实现的最终结果,这就是所谓的 BDD,即行为驱动开发了。但特性测试并非 BDD 的全部作用范围,接下来说另外一个重要的环节——

集成测试:Integration Test,有时候也叫 Functional Test,但要注意 Functional 是功能性的测试而不是功能测试,这是两码事。功能测试测试的是某一项功能(特性)的外部表现,主要是面向用户(包括测试人员)的;而功能性测试则是为了测试系统的某一个/几个部件的功能性是否完备,这些部件有可能是跨越多个功能的。比方说用户认证组件,它就可以跨越多个功能:注册,邀请,登录,资料修改,第三方授权等等。为了避免混淆,还是叫集成测试为好。

集成二字道出真义,此类测试的目的是为了检验各单元协同工作的成果,因此它就是单元测试的直接“上级”了。有时候很容易把集成测试和单元测试混为一谈,这是因为集成测试可以使用和单元测试一样的工具/框架来编写,也就是说集成测试也可以践行 TDD 的实践原则;当然,集成测试也可以用 BDD 向的工具/框架来编写,这个界限是非常模糊的。

牢牢记住:集成测试和单元测试的区别不在于使用了什么样的工具/框架,也不在于践行了哪种测试驱动方法,而在于它们谁更看重对待测代码的隔离性。

集成测试关心的是几个代码单元交互的时候是否能正常工作,因此参与测试的这些代码单元必须是真实的(即你编写的实际代码),它不看重隔离性,就是要检查代码在耦合状态下的真实行为;单元测试则恰好相反,它只关心一个特定代码单元自身是否正常工作,如果这种工作一定要有外部依赖,那么单元测试不惜伪造这些依赖也要尽量避免让不属于这个单元的其他真实代码掺合进来。

理论上,如果单元测试编写的足够健壮,那么这些单元组合起来协同工作就应该能通过相应的集成测试。然而实际中我们都不是神仙,谁也无法预估复杂性上升之后的所有可能结果,因此集成测试是对系统集成行为的一个重要保护伞,不应忽视其重要性。事实上如果践行 BDD 原则的话,集成测试应该要写在单元测试之前,即“先描述其行为,再描述参与该行为的各个单元如何实现自己的职能”。

还有一件事情比较容易让开发者陷入两可又两难的困境,即特性测试和集成测试的覆盖范围完全相等。在这种情况下,开发者很容易写出一模一样的特性测试和集成测试来,如何解决?很简单,只要记住“集成测试是对内的,特性测试是对外的”这句话就好。

比方说你要测试用户进入 /test 这件事情,对于集成测试来说可能至少涉及到三个组件:路由、控制器、视图,因此你的代码应当描述的是:路由的跳转,控制器对应方法的调用,视图对于模版的查找和渲染这些过程,千万不要直接去触碰最终的用户界面,因为这超出了你的责任范围。

而对于特性测试来说由于它是对外的,你应该从用户(包括测试人员)的角度来审视其过程。用户可不知道什么路由控制器视图……他/她只知道:我输入了 /test 的链接,我期望看到 xxx 的用户界面。这就够了。特性测试借助浏览器的驱动器,可以模拟 URL 跳转,可以获取页面上的任何元素,也可以模拟更复杂的用户交互,但这些都是发生在浏览器里(也就是用户界面)的事情,不能出现发生在系统(框架)里面的事情。

这就是为什么特性测试应该由测试人员来编写的缘故,因为他们恰好不关心发生在用户界面以外的事情;另一方面集成测试就应该由程序员自己来写,因为只有你才知道系统内部是怎么工作的。

单元测试:看起来没什么选择了,单元测试也只能是程序员自己的责任吧?话是没错,但我们现在不是假设理想情况吗?所以理想状况下,即使单元测试只能由程序员来编写,程序员也(最好)不要针对自己实现的代码来编写单元测试。谁来?其他程序员。

没错,我说的就是结对编程。给自己的代码编写单元测试当然不是不可以,但是一个人的思路永远是不够开放的——老祖宗说了:不识庐山真面目,只缘身在此山中嘛。你能保证自己的思路完全正确吗?你能保证自己已经考虑到了所有的边界条件吗?你确信只有这样也是解决问题的最好方式吗?

谁都不能完全做到,即使是结对编程也还是不足以十全十美,但换一个人来为你描述问题往往能收获非常好的效果。我清楚结对编程的代价,但是很多人都不了解结对编程的巨大好处,为同事编写测试(或反过来)只是其中之一,个中妙处你只有经历过才知道,这里就不多谈了。总之如果有什么办法能迅速提高单元测试的质量,我的答案就是结对编程。

避免无用的测试

现在来说什么时候没必要写单元测试,这里提到的单元测试也同样包括不遵循 TDD 原则所编写的测试,比如说先写代码后写测试的情形。

到底什么是无用的测试,我说了其实不算,只有写代码的人才真正知道。但如果你刚接触测试,确实不清楚哪些该写哪些不该写,那么我有两个建议:

  1. 只写必要的测试:什么是必要的测试我已经在 TDD 的时机那一节讲过了,如果你不知道要写哪些测试,就先从此处开始;

  2. 只写关键的测试:有时候必要的测试你写不出来,身边又没有人辅导,那也勉强可以跳过。但是关键性测试不要省。所谓关键性的测试,就是你所写代码里的核心逻辑;再换句话说就是如果一切顺利,它至少能够做到(或者不要去做)的那件事。这就意味着你可能忽略了一些边界条件的处理,而且你也不知道该怎么处理,但是你至少保证了最重要的那条路线是可以走通的。将来重构的时候,这条关键路线就像夜空中的北极星一样能确保你不至于茫然无措。
    如果在构造关键性测试用例的时候你发现你很难触碰到那一点(比如说前置条件你不会在测试用例里处理),那么很大的可能是你的这个单元过于复杂了,这是一个极好的立时重构信号。你可以尝试把要触碰的那一点逻辑抽取出来单独测试。这样一来你至少做到了把核心逻辑分离出来,其他的代码就算再糟糕重构起来也会轻松得多。

至于无用的测试,虽然无法在此一一列举,但也可以总结几条:

  1. 不要去测试语言的核心库和/或标准库函数:如果你的代码简单到就调用了一句标准库函数,那还浪费什么时间去编写测试啊,这些代码都是久经考验的。虽然也有语言本身错误的小概率事件发生,但由于标准函数的处理过程你触碰不到(常常深埋于虚拟机中或调用系统底层接口)所以你的测试对你自己的代码丝毫没有帮助。(当然,如果您是专家级程序员则不在此例,说不准您就是等着解决这个问题呢)
  2. 不要去测试框架的基础类或工具方法:道理和第一条类似,知名的框架都有很完备的自身测试,否则你也不敢用不是?如果你确信是框架自身出了问题,你的测试更应该去应用在框架本身上,说不定你可以做出个补丁为该项目做出贡献。
    顺手举个例子:你继承了某框架的 Model 层,然后在里面定义了检查其实例的某一属性是否为空的验证(使用框架自带的验证方法,而不是自己编写的)。这种情况就没有必要测试这个检查是否生效,除非你这个类在初始化的时候返回的是其他类的实例……你项目组里有这么无聊的人吗?
  3. 不要去测试外部依赖的有效性:这是初学者常常陷的坑,而且往往把自己折磨到不行。
    这里有两个问题:第一,如果你的测试一定需要外部依赖,你首先应该考虑伪造它,而不是在 A 的测试里先检查 B(也就是说,你的测试目标是 A,为了完成这个测试用例,你需要用到 B 并且 B 的某种特性一定要成立,这是先决条件,于是你不得不写一句断言先测试这件事情,然后才能测试真正的目标 A)。如果你能伪造一个 B,叫 B’,那么 B’ 不一定非要和 B 完全一样,只要它能表现出来恰好满足本次测试用例的特征就足够了。这样事情就会变得单纯的多。
    其次,即使你无法伪造 B(基本上是因为不会),那么你至少应该把对 B 的特性测试转移到 B 自己的单元测试中去。

最后还有一种测试是“无用”的,那就是从来只见它绿没见过它红的测试。你自己都没意识到这种测试可能从头到尾都没有测试任何代码!这也是 TDD 强调先红后绿再重构的原因之一,你至少应该在最开始让测试用例失败一次,否则等测试数量变多以后再去分辨就来不及了。另外重构完了也最好手动破坏一下代码(比如随便往里面打几个无意义的字符)诱使测试报错,以确保测试真的覆盖到了目标代码。

测试的代码覆盖率

我对“无用测试”的态度已经揭示出我对代码覆盖率的态度:无视它。我一直认为代码覆盖率是最形式主义的技术工具,覆盖率再高也不能保证代码本身无懈可击,该出 Bug 的地方 100% 的覆盖率也救不了你。

其实作为一种辅助度量工具,代码覆盖本身并没有什么错,有位仁兄说得好:“在追求精益求精的道路上,我们应该无所不用其极”。错就错在拿代码覆盖率当考核指标,以此来衡量测试人员的工作水平,对此我相当无语,也相当反感。

有识之士一定会说:你也不要以偏概全,路径覆盖所度量出的代码覆盖率还是相当靠谱的嘛。

简单普及下,代码覆盖算法有很多种,大致上对比准确性:路径覆盖 > 条件覆盖 ~= 判定覆盖 > 语句覆盖。而且这只是说条件分支,循环什么的还有别的算法就不多说了。这些算法在覆盖率都达到 100% 的前提下,其“靠谱”程度可能有天壤之别。问题就出在下决策使用代码覆盖率做考核的人往往不明白这种差别,这就给了落地执行的人可趁之机,很容易就演变成了“在追求 100% 代码覆盖率的道路上,我们应该无所不用其极“。若是连落地执行人都不懂,那就更悲剧了,一群人对着水份极大的 100% 乐得嘴都合不拢,想想都难受。

所以对于代码覆盖率的不当应用,只会让大家越走越偏,浪费时间不说收效还甚微;反过来恰当的使用代码覆盖率又对团队的要求极高,只有一个人懂行是不够的,因为你没有那么多时间精力去检查结果是不是真的靠谱。如果每一个人都按照靠谱的方式去写代码和测试,不用测试覆盖率也没什么大不了的。因此如果我是初创团队的负责人,我宁可选择把时间和精力放在测试用例本身上,测试本身靠谱了,测试覆盖率的辅助价值才能靠谱。

转载于: https://segmentfault.com/q/1010000000692485

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值