Heim > Artikel > Backend-Entwicklung > 谈一下单元测试在软件开发中的作用?
最近开始了解单元测试方面的事情,主要是学习PHPUnit,目前只是跟着PHPunit手册和一个入门教程 PHPUnit 入门与实战在学习,只是简单的了解,没有深入的探究。
酷壳作者有以下的观点:
软件开发是一种脑力劳动,是一种知识密集型的工作,就像艺术作品一样,创作过程和成品是没有标准答案的。
软件的质量不是测试出来的,而是设计和维护出来的。就像工匠们在一点一点地雕琢他们的作品一样。
希望大家可以来交流下自己或者所在团队对单元测试的看法,以及在实际项目中的应用。
比如:
最近开始了解单元测试方面的事情,主要是学习PHPUnit,目前只是跟着PHPunit手册和一个入门教程 PHPUnit 入门与实战在学习,只是简单的了解,没有深入的探究。
酷壳作者有以下的观点:
软件开发是一种脑力劳动,是一种知识密集型的工作,就像艺术作品一样,创作过程和成品是没有标准答案的。
软件的质量不是测试出来的,而是设计和维护出来的。就像工匠们在一点一点地雕琢他们的作品一样。
希望大家可以来交流下自己或者所在团队对单元测试的看法,以及在实际项目中的应用。
比如:
只回答问题本身的话,我个人的感受就是一句话:
单元测试的作用归根结底就是保护,甚至提升开发者对代码的自信心(无论是自己的代码还是团队其他成员写的代码都有可能因此受益),而且无论是测试即有代码还是为编写新的代码,亦或是为重构老代码做准备,这一点(保护和提升自信心)都是成立的。
以上就算 tl;dr 了,以下将就以上观点写一些个人的体会。
单元
测试单元测试是测试的一个子类,并非写了测试就叫单元测试,甚至你用了单元测试框架也有可能写出越过单元测试边界的代码。正确的单元测试就是确保测试代码准确隔离(isolate)了待测代码,如果你测试一个类,那么测试代码中就应该避免出现对于其他类的依赖(语言的标准库或者框架提供的工具方法/助手方法例外),甚至你测试该类的某个方法都要尽量避免对类内部其他成员的依赖。
当然这在现实里是不可能的,100% 没有依赖没有耦合的代码是不存在的,即使存在也没啥实际用处。我们不可避免的要让代码彼此交互,这种交互也不可避免的要体现在测试代码中。后面我会讲到一些解决的办法,不过在最开始我需要强调单元测试的根本性质,这样你才不会误以为剩下的内容讲的是集成测试或者验收测试什么的。
再强调一次:单元测试的根本性质就是要正确隔离待测代码。如果这种说法很难理解,我试着举一个通俗的例子:
如果你要测试 一只狗是不是吃饱了,那么相应的单元测试里就不应该依赖 根据这只狗何时吃的上顿饭,吃的东西是什么,吃的量有多少,谁喂的这一顿……等等前置条件 才能做出断言,特别是当这些前置条件要依赖系统的其他组件才能产生的情况下就更要小心!即使该方法(比如说 a_dog.is_full
)的返回结果的确要依赖前置条件才能正确输出,单元测试本身也 不应该浪费精力在塑造这些前置条件上,而是应该把重点放在 测试和保障该方法的返回结果是预期的并且在可预见的各种边缘条件下该方法的返回结果都不会超出预期 之上。至于各种前置条件(包括边缘条件),可以伪造(后面会讲)它们而不是去调用真正生成它们的其他代码,只有这样才能保证“隔离性”,才能称的上是单元测试。
我见过同事埋冤甚至咒骂写单元测试这件事情,我其实很能理解他们的心情而且我也清楚症结在哪里(浪费太多精力在创造完成断言的前置条件上),其实就差这一层窗户纸,只要能理解“隔离”这两个字在单元测试中的意义就能捅破它。
TDD,即测试驱动开发,是一种利用测试受益的方法论(或者说实践准则)。简单地说,TDD 就是在写代码前先写测试,并严格遵循 red => green => refactor(错误 => 正确 => 重构)的流程,所以才叫做“测试驱动
开发”。驱动
这两个字才是 TDD 的核心思想。
然而 TDD 并非完美无缺,很多高水平的程序员都对 TDD 颇有微词(非等级化歧视,只是因为富有经验者才容易体会到一些问题,他们大部分都是高水平程序员——除了我以外……),总的来说就是认为 TDD 会影响开发的效率甚至在某些极端情况下会阻碍开发的顺利进行。在这里我尽量不说关于 TDD 的坏话,而是说它能带来的帮助。
刚才提到“驱动”二字才是 TDD 的核心思想,因此若想避免 TDD 可能带来的问题,我们就要利用好 TDD 能够驱动开发的这个特性。那么开发者何时需要测试来驱动?我认为大致有两种情形是适合(甚至是必要)的:
准备编写自己觉得“没谱”的代码
觉得“没谱”可能存在多种原因,比如说业务逻辑很复杂自己没完全吃透;比如说脑中大概有一个思路,但由于以前没写过所以吃不准是不是能行,也无法确定过程中是否会产生难以预料的变数等等。
如果不存在测试这回事,你会如何应对上面的情形?好一点的可能会把思路整理一下写个步骤列表或者画个流程图什么的,比较糟糕的则是先动手写了再说,万一不行再改。这时候你应该尝试 TDD 了,但是到底怎样做呢?
其实但凡是测试其基本原理都是一样的:你给测试用例一种输入,然后断言其结果,最后执行并观察断言是否正确;以此类推,你写 N 个测试用例,每一个都覆盖某种可能的输入(边界条件)并断言可能的结果(结果可能是返回值,也可能是某种行为所体现的特征),然后执行观察结果。如是而已。
TDD 也是测试,所以不用想复杂了,它遵循一样的原理,只不过是在没有代码的前提下先写测试罢了。因此你甚至不需要把代码的整个处理过程理清楚,只需要想好边界条件有哪些(这是目标代码的输入或前置条件。另外,边界条件的意义就在于不需要穷举所有条件,只要能覆盖就足够),对应的结果应该是什么就可以写出足够的测试用例了。这有点像为大型的复杂架构设计接口,你不去考虑具体实现而是考虑输入输出,我喜欢称之为:思路黑盒化。之后就是运行代码看它失败,接着写代码让它成功,此时你有了可靠的测试用例于是可以立即着手优化或重构代码,直到最终交付。
所有的测试都是如此,不是么?不过就 TDD 本身再强调两件事情:
从第一次测试失败到第一次测试成功,这个过程不应该是一步实现的(除非你的代码实在是太简单了,但这样的话也就没必要非 TDD 不可了)。每一次你写下代码,它们唯一的目的就是要解决上一次测试失败的原因,从而让测试产生新的(进一步的)失败,直到测试成功为止,这就是俗话说的“小步快速走”的测试策略,它的好处很多,比如说可以把你的思考总是保持在可控范围内,每一步都只需要解决简单的问题,最终解决一个复杂的问题;再比如说有助于你写出设计良好,更加健壮,更加易懂的代码等等。而且由于这个过程是反复的,因此重构的环节也不一定非要放到最后才开始,你完全可以在中间某处就开始重构。这就好像随着思路的逐渐清晰和明朗,你忽然意识到还有某种更好的方法可以利用,于是你不必非得后知后觉。
测试产生的失败分为两种,一种是代码抛出的异常(比如说类或方法不存在,某处写错了名字等等),另一种是断言的条件没有满足(其中也可能包括对于异常处理的断言哦,要注意区别)。严格来说,只有第二种才叫测试失败,第一种应该算是语言本身的正常反应(你写错了嘛),所以一定要注意区分二者。绝大部分情况下这两种情形的输出都是有显著差异的,所以应该不难辨别。千万别让自己因为语言抛出异常却不自知,反而使劲儿和测试代码硬磕,这种低级错误需要杜绝。
准备重构即有代码,有可能是改善,也有可能是添加新的功能特性或处理新条件的逻辑,再有就是修复 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 向的工具/框架来编写,这个界限是非常模糊的。
牢牢记住:集成测试和单元测试的区别不在于使用了什么样的工具/框架,也不在于践行了哪种测试驱动方法,而在于它们谁更看重对待测代码的隔离性。
集成测试关心的是几个代码单元交互的时候是否能正常工作,因此参与测试的这些代码单元必须是真实的(即你编写的实际代码),它不看重隔离性,就是要检查代码在耦合状态下的真实行为;单元测试则恰好相反,它只关心一个特定代码单元自身是否正常工作,如果这种工作一定要有外部依赖,那么单元测试不惜伪造这些依赖也要尽量避免让不属于这个单元的其他真实代码掺合进来。
小贴士:集成测试就完全不去考虑隔离吗?这也不一定。有时候系统中的某个组件会依赖外部服务,比方说第三方 API 的调用,这时候是可以适当模拟这种请求的,这就是一种隔离,其目的是为了避免远程调用造成的超长测试周期,但要注意确保这种模拟的正确性。
理论上,如果单元测试编写的足够健壮,那么这些单元组合起来协同工作就应该能通过相应的集成测试。然而实际中我们都不是神仙,谁也无法预估复杂性上升之后的所有可能结果,因此集成测试是对系统集成行为的一个重要保护伞,不应忽视其重要性。事实上如果践行 BDD 原则的话,集成测试应该要写在单元测试之前,即“先描述其行为,再描述参与该行为的各个单元如何实现自己的职能”。
还有一件事情比较容易让开发者陷入两可又两难的困境,即特性测试和集成测试的覆盖范围完全相等。在这种情况下,开发者很容易写出一模一样的特性测试和集成测试来,如何解决?很简单,只要记住“集成测试是对内的,特性测试是对外的”这句话就好。
比方说你要测试用户进入 /test
这件事情,对于集成测试来说可能至少涉及到三个组件:路由、控制器、视图,因此你的代码应当描述的是:路由的跳转,控制器对应方法的调用,视图对于模版的查找和渲染这些过程,千万不要直接去触碰最终的用户界面,因为这超出了你的责任范围。
而对于特性测试来说由于它是对外的,你应该从用户(包括测试人员)的角度来审视其过程。用户可不知道什么路由控制器视图……他/她只知道:我输入了 /test
的链接,我期望看到 xxx 的用户界面。这就够了。特性测试借助浏览器的驱动器,可以模拟 URL 跳转,可以获取页面上的任何元素,也可以模拟更复杂的用户交互,但这些都是发生在浏览器里(也就是用户界面)的事情,不能出现发生在系统(框架)里面的事情。
这就是为什么特性测试应该由测试人员来编写的缘故,因为他们恰好不关心发生在用户界面以外的事情;另一方面集成测试就应该由程序员自己来写,因为只有你才知道系统内部是怎么工作的。
单元测试:看起来没什么选择了,单元测试也只能是程序员自己的责任吧?话是没错,但我们现在不是假设理想情况吗?所以理想状况下,即使单元测试只能由程序员来编写,程序员也(最好)不要针对自己实现的代码来编写单元测试。谁来?其他程序员。
没错,我说的就是结对编程。给自己的代码编写单元测试当然不是不可以,但是一个人的思路永远是不够开放的——老祖宗说了:不识庐山真面目,只缘身在此山中嘛。你能保证自己的思路完全正确吗?你能保证自己已经考虑到了所有的边界条件吗?你确信只有这样也是解决问题的最好方式吗?
谁都不能完全做到,即使是结对编程也还是不足以十全十美,但换一个人来为你描述问题往往能收获非常好的效果。我清楚结对编程的代价,但是很多人都不了解结对编程的巨大好处,为同事编写测试(或反过来)只是其中之一,个中妙处你只有经历过才知道,这里就不多谈了。总之如果有什么办法能迅速提高单元测试的质量,我的答案就是结对编程。
现在来说什么时候没必要写单元测试,这里提到的单元测试也同样包括不遵循 TDD 原则所编写的测试,比如说先写代码后写测试的情形。
到底什么是无用的测试,我说了其实不算,只有写代码的人才真正知道。但如果你刚接触测试,确实不清楚哪些该写哪些不该写,那么我有两个建议:
只写关键的测试:有时候必要的测试你写不出来,身边又没有人辅导,那也勉强可以跳过。但是关键性测试不要省。所谓关键性的测试,就是你所写代码里的核心逻辑;再换句话说就是如果一切顺利,它至少能够做到(或者不要去做)的那件事。这就意味着你可能忽略了一些边界条件的处理,而且你也不知道该怎么处理,但是你至少保证了最重要的那条路线是可以走通的。将来重构的时候,这条关键路线就像夜空中的北极星一样能确保你不至于茫然无措。
如果在构造关键性测试用例的时候你发现你很难触碰到那一点(比如说前置条件你不会在测试用例里处理),那么很大的可能是你的这个单元过于复杂了,这是一个极好的立时重构信号。你可以尝试把要触碰的那一点逻辑抽取出来单独测试。这样一来你至少做到了把核心逻辑分离出来,其他的代码就算再糟糕重构起来也会轻松得多。
至于无用的测试,虽然无法在此一一列举,但也可以总结几条:
不要去测试语言的核心库和/或标准库函数:如果你的代码简单到就调用了一句标准库函数,那还浪费什么时间去编写测试啊,这些代码都是久经考验的。虽然也有语言本身错误的小概率事件发生,但由于标准函数的处理过程你触碰不到(常常深埋于虚拟机中或调用系统底层接口)所以你的测试对你自己的代码丝毫没有帮助。(当然,如果您是专家级程序员则不在此例,说不准您就是等着解决这个问题呢)
不要去测试框架的基础类或工具方法:道理和第一条类似,知名的框架都有很完备的自身测试,否则你也不敢用不是?如果你确信是框架自身出了问题,你的测试更应该去应用在框架本身上,说不定你可以做出个补丁为该项目做出贡献。
顺手举个例子:你继承了某框架的 Model 层,然后在里面定义了检查其实例的某一属性是否为空的验证(使用框架自带的验证方法,而不是自己编写的)。这种情况就没有必要测试这个检查是否生效,除非你这个类在初始化的时候返回的是其他类的实例……你项目组里有这么无聊的人吗?
不要去测试外部依赖的有效性:这是初学者常常陷的坑,而且往往把自己折磨到不行。
这里有两个问题:第一,如果你的测试一定需要外部依赖,你首先应该考虑伪造它,而不是在 A 的测试里先检查 B(也就是说,你的测试目标是 A,为了完成这个测试用例,你需要用到 B 并且 B 的某种特性一定要成立,这是先决条件,于是你不得不写一句断言先测试这件事情,然后才能测试真正的目标 A)。如果你能伪造一个 B,叫 B',那么 B' 不一定非要和 B 完全一样,只要它能表现出来恰好满足本次测试用例的特征就足够了。这样事情就会变得单纯的多。
其次,即使你无法伪造 B(基本上是因为不会),那么你至少应该把对 B 的特性测试转移到 B 自己的单元测试中去。
最后还有一种测试是“无用”的,那就是从来只见它绿没见过它红的测试。你自己都没意识到这种测试可能从头到尾都没有测试任何代码!这也是 TDD 强调先红后绿再重构的原因之一,你至少应该在最开始让测试用例失败一次,否则等测试数量变多以后再去分辨就来不及了。另外重构完了也最好手动破坏一下代码(比如随便往里面打几个无意义的字符)诱使测试报错,以确保测试真的覆盖到了目标代码。
我对“无用测试”的态度已经揭示出我对代码覆盖率的态度:无视它。我一直认为代码覆盖率是最形式主义的技术工具,覆盖率再高也不能保证代码本身无懈可击,该出 Bug 的地方 100% 的覆盖率也救不了你。
其实作为一种辅助度量工具,代码覆盖本身并没有什么错,有位仁兄说得好:“在追求精益求精的道路上,我们应该无所不用其极”。错就错在拿代码覆盖率当考核指标,以此来衡量测试人员的工作水平,对此我相当无语,也相当反感。
有识之士一定会说:你也不要以偏概全,路径覆盖所度量出的代码覆盖率还是相当靠谱的嘛。
简单普及下,代码覆盖算法有很多种,大致上对比准确性:路径覆盖 > 条件覆盖 ~= 判定覆盖 > 语句覆盖。而且这只是说条件分支,循环什么的还有别的算法就不多说了。这些算法在覆盖率都达到 100% 的前提下,其“靠谱”程度可能有天壤之别。问题就出在下决策使用代码覆盖率做考核的人往往不明白这种差别,这就给了落地执行的人可趁之机,很容易就演变成了“在追求 100% 代码覆盖率的道路上,我们应该无所不用其极“。若是连落地执行人都不懂,那就更悲剧了,一群人对着水份极大的 100% 乐得嘴都合不拢,想想都难受。
所以对于代码覆盖率的不当应用,只会让大家越走越偏,浪费时间不说收效还甚微;反过来恰当的使用代码覆盖率又对团队的要求极高,只有一个人懂行是不够的,因为你没有那么多时间精力去检查结果是不是真的靠谱。如果每一个人都按照靠谱的方式去写代码和测试,不用测试覆盖率也没什么大不了的。因此如果我是初创团队的负责人,我宁可选择把时间和精力放在测试用例本身上,测试本身靠谱了,测试覆盖率的辅助价值才能靠谱。
前面我多次提到了测试用例中的“伪造”技术(或者是“伪装”技术),对于初学者来说一定觉得很困惑吧?这是一种编写测试的进阶技巧,说是进阶但其实它并不难,只是一来概念上比较抽象,理解它需要花点心思,二来“伪造”技术流派甚多,极易让人混淆接着望而生畏,以至于知道也不敢大胆使用。在此我借机会谈谈我的理解和心得,望能助你解惑。
由于不同的语言不同的平台有自己的发展历史和文化背景,所以会出现相似甚至相同的概念却有着不同的称谓的现象,测试中的“伪装术”就是其中的典型代表,而且它的情况更复杂,很多名词术语都和测试中的“伪装术”有关系,它们有:fakes, doubles, stubs, mocks, spies……说不定还有更多我不知道的!
顺便一提,它们的动名词形式,如 mocking,代表一种“伪装”的技术;而它们的名词形式,如 mocks 代表应用了此种“伪装”之后的产物。
它们的复杂性在于:基本上你可以认为它们是一回事儿,但是在某些细节上各有不同。这就好比老王家生了五个儿子,他们都是老王家的儿子,但是他们各有各的特点;对外人来说(较高的抽象层面上),知道他们都是老王家的儿子就够了,但对老王自家人来说(更具体的层面),就可以分辨出各个的不同来了。
它们的复杂性还在于:不同的语言平台或社区里,它们中的一些常常是等价的,甚至其中的一些干脆就完全不提(这种界限上的模糊往往来自于语言自身的特性,比如说动态语言相对于静态语言更加灵活,往往没必要在对象的类型上太较真)。
有鉴于此,我没打算逐个详细的介绍,这太困难了!我只介绍其概念和基本用法,在界限足够分明的时候再提及它们的名字。
OK,什么是测试伪装术?
你特别喜欢下象棋,不过下棋这事儿一个人是做不了的,所以你最喜欢的是和老王一起下象棋,你俩棋逢对手将遇良材脾气也对味,一起下棋最是过瘾(外部依赖需求满足条件),这要是换了别人,下棋没了乐趣不说还说不定得打起来(外部依赖需求不满足条件)。
有一天半夜,你梦见经一老神仙授艺,你的棋力大涨三段。这一乐就给醒了,棋瘾犯了怎么也睡不着,就像找个对手来一较高低(待测目标)。但找谁呢?这么晚了也不好把老王从被窝里拽出来吧?(单元测试的隔离特性,源自于外部依赖的不确定性)你灵机一动,用电脑模拟了一个老王出来(测试伪装术登场),对于此时的你来说,他是不是真的老王并不重要,重要的是“他”能满足你的要求。
如上所述,你用电脑模拟的老王就是一个伪装对象了——一个用伪装术创造出来的对象。本质上它就是一个普通的对象,甚至可以手工去创造它。但是为了方便起见,我们往往会使用一些专用的伪装库来生成伪装对象,这些伪装库为我们提供了一些方法来协助我们仿造对象的属性和行为以满足测试用例的各种需求。重要的是,伪装对象和真实的外部依赖对象一点关系都没有,这就使得我们的单元测试可以在完全隔离外部依赖的前提下运行生效,因此即使实际代码里没有任何外部依赖存在的痕迹,我们依旧可以在假设外部依赖存在的前提下完成我们的目标代码实现。
伪装对象,有时候称之为 Faked Object 或 Fakes,也有称之为 Doubled Object 或 Doubles 的,并且不同的语言环境下对于伪装对象的严格定义也会有差别,不过其原理都是一致的。以下我们统一用 double 来称呼伪装对象。
还有一种分类法是把所有类型的伪装对象统一称作 doubles,然后再细分类型,这样分下来,dummies,fakes,stubs,mocks,spies 这些是子类型,各有各的特点。
本文不做学术研讨之用,你喜欢那一种就用那一种好了,无所谓的。
伪装对象可以和外部依赖一模一样,也可以完全不同,这个差别取决于你的测试用例到底依赖它多少。根据测试用例来塑造伪装对象的特性(可以理解为仿真度),由此而发展出各种伪装技术来。
你模拟了一个老王还不够,还希望这个“假老王”可以和真老王一样的五官眉眼,胖瘦高低,这样你才有身临其境的感觉。
这就是对外部依赖有类型匹配要求,需要模拟其属性了。模拟属性不是难事,但是能否严格的满足类型匹配要求往往要视语言环境和测试工具的能力而定。我用 RSpec 写几个简单的例子(题主请原谅我不懂 PHP):
<code class="lang-ruby">wang = double() # or wang = double('player') </code>
这个 wang
就是个 double 对象,什么类型不关心。
<code class="lang-ruby">wang = instance_double('Player') </code>
这个 wang
就不一样了,它一定是 Player
类的实例,如果 Player
类不存在则测试抛出错误。
<code class="lang-ruby">wang = instance_double('Player', name: 'Wang') expect(wang.name).to equal('Wang') </code>
这是模拟属性的例子,很直白(为节省篇幅只写关键代码)。顺便一提,如果一个 double 带有“预置”的返回值用以响应对特定方法的调用,我们就叫它 stub,响应的其伪装技术就叫 stubbing。比如本例中,创建 wang
这个 double 的时候就为其指明了 name
属性,于是断言里 wang.name
调用时就会返回预先定义好的 Wang
。这个例子可以写的更复杂(但更容易理解)些:
<code class="lang-ruby">wang = instance_double('Player') allow(wang).to receive(:name).and_return('Wang') expect(wang.name).to equal('Wang') </code>
如果你英语还行,你会发现上例读起来相当流畅!试着翻译一次:
wang
是Player
的实例 double
允许wang
接受消息(即方法调用):name
并且返回字符串Wang
期望wang.name
的返回结果等于字符串Wang
这样的代码风格是典型的 BDD 式的代码风格,BDD 需要对代码的行为进行准确描述,因此基本上都会创造一些可读性非常好的 DSL(领域特定语言)来帮助你编写测试。不过这也不是绝对的就是了。
在你看来,仅仅看着像老王还不足够,你更需要它能够像老王一样的下棋
比如说老王很喜欢用炮,所以一旦丢了炮,他会很恼火,还会耍赖悔棋……
更进一步的,我们不但希望模拟外部依赖的类型和属性,还希望模拟它们的行为,特别是和待测目标交互时产生的行为。这样的测试如果不加以隔离将会产生至少两种后果:
如果外部依赖变更,则会导致测试失败。严格来说这种后果不是测试的责任,外部依赖的变更应该保持外部接口不变和返回结果不变,只变更内部的行为。使用伪装术的好处就在于一旦出现这种情况不至于让你误以为是己方的代码除了问题。
当然你也会想,如果用了伪装对象,那么外部依赖变了己方的测试还浑然不知,这不是很危险吗?有道理,不过单元测试的职责是测试己方代码的正确性,对于外部依赖的模拟不一定非得和模拟对象完全一致,真实的交互应该先由集成测试来捕捉问题,否则很容易迷失在复杂的代码交互之中。
<code class="lang-ruby">wang = double(name: 'Wang').as_null_object cannon = double(side: :black, ...) expect(wang).to receive(:ask_for_takeback).with(cannon).at_least(:once) me.taking(cannon) </code>
你会注意到,这里是先断言老王会要求悔棋(至少要求一次),然后才是吃子。这就好像一个触发器,你先宣称依赖对象在你做出特定行为后肯定会表现出对应的行为,之后你做一下试试看是不是会发生。
这种先断言后调用的伪装技术称之为 mocking,有“糊弄人”的意思在里面(可不是么!),它还有一个比较学术化的正统称谓,叫做 Message expectations。也有一些程序员不喜欢这种风格,他们觉得先调用后断言才更合乎常理,于是就有了非常类似的 spies,如下:
<code class="lang-ruby">me.taking(cannon) expect(wang).to have_received(:ask_for_takeback).with(cannon).at_least(:once) </code>
上述介绍皆以 RSpec3 为参考测试框架,或许对于其他语言的其他框架来说一些概念和用法会大相径庭,这我真说不好,所以参考为主,不必尽信了。重要的是了解这些技术到底是干啥的,对编写测试有啥帮助,具体的技术细节还是要以你使用的工具的文档为准哦。
在我个人的日常开发中,还有用到以下几种工具来编写测试:
HTTP 请求拦截/模拟
这个主要是前端用,在测试代码里没有必要真的向后端发起请求(主要是异步的,也就是 Ajax),只需要拦截真实的异步请求,然后返回一些伪数据就够了。伪数据可以写死在测试代码里(如果量不大),也可以存成独立的文件,好在现今基本都是使用 JSON 做数据传输,Javascript 处理起来很方便的。
最近使用的此类工具是 Pretender,很简单也很好用。当然还有老牌的 Sinon 也是不错的选择,不过 Sinon 原则上主要还是用于 mocking/stubbing。
Fixture 生成器
Fixture 还是一种特殊的伪装对象了,只不过有时候用单纯的 doubles 去模拟还是挺累的(因为代码重复),于是就有人发明了一些专门帮你生成伪装对象的工具,能批量生成,能即时修改,能随机生成,减少你的代码重复。
前端方面不知道有什么好工具(好像也不太用得着),后端 Ruby 这块儿主要是用 FactoryGirl
啊,好累,写不动了……关于测试的话题我也完成了长文总结,又一个目标达成!先就到这里吧,等想起来啥了再添,或者有问题再补充答案好了。
针对你的问题 一般是由程序员在开发之前写测试用例