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