单元测试理论中,那些常见的思考!
补测试的建议很多时候,项目的开发受限于时间或人力等资源没条件履行 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 跳转,可以获取页面上的任何元素,也可以模拟更复杂的用户交互,但这些都是发生在浏览器里(也就是用户界面)的事情,不能出现发生在系统(框架)里面的事情。
这就是为什么特性测试应该由测试人员来编写的缘故,因为他们恰好不关心发生在用户界面以外的事情;另一方面集成测试就应该由程序员自己来写,因为只有你才知道系统内部是怎么工作的。
单元测试:看起来没什么选择了,单元测试也只能是程序员自己的责任吧?话是没错,但我们现在不是假设理想情况吗?所以理想状况下,即使单元测试只能由程序员来编写,程序员也(最好)不要针对自己实现的代码来编写单元测试。谁来?其他程序员。
没错,我说的就是结对编程。给自己的代码编写单元测试当然不是不可以,但是一个人的思路永远是不够开放的——老祖宗说了:不识庐山真面目,只缘身在此山中嘛。你能保证自己的思路完全正确吗?你能保证自己已经考虑到了所有的边界条件吗?你确信只有这样也是解决问题的最好方式吗?
谁都不能完全做到,即使是结对编程也还是不足以十全十美,但换一个人来为你描述问题往往能收获非常好的效果。我清楚结对编程的代价,但是很多人都不了解结对编程的巨大好处,为同事编写测试(或反过来)只是其中之一,个中妙处你只有经历过才知道,这里就不多谈了。总之如果有什么办法能迅速提高单元测试的质量,我的答案就是结对编程。
页:
[1]