3.3 使用测试替身的指南
测试替身是程序员的工具,就像木匠的锤子和钉子。存在敲钉子的适当方式,当然也有不恰当的方式——最好是能把它们识别出来。
先从我认为最重要的指南开始吧,当你求助于测试替身时要时刻牢记它——从你的工具箱中选择合适的工具。
3.3.1 为测试挑选合适的替身
有许多测试替身可供选择,它们看起来各有千秋。采用它们的最佳条件是什么?到底应该选择哪个?
这里并没有太多的硬性规定,但一般来说你应该因地制宜地混合使用。我是说,某些情况下你只想要“一个返回5的对象”,而其他情况下你特别想知道某个方法被调用过。有时在一个测试中对两者都感兴趣,于是你将Stub、Fake和Mock一同使用。
前面已经说过,并没有清晰的原则来决定采用哪种方式以得到最可读的测试。但我还是忍不住对如何选择这个问题阐述一些逻辑和启发:
如果你关心某些交互,即两个对象之间的方法调用,你可能会需要一个模拟对象Mock。
如果你决定使用Mock,但测试代码最终看起来不像你想的那样漂亮,那就看看一个手工的简单测试间谍Spy能否满足需要。
如果你只关心协作对象向被测对象输送的响应,用桩Stub就可以。
如果你想运行一个复杂场景,其中它所依赖的服务或组件无法供测试使用,而你对所有交互打桩的快速尝试却戛然而止,或产出了难以维护的糟糕的测试代码,那就考虑实现一个伪造对象Fake吧。
如果上述都不能满足你手上的特殊情况,那就抛硬币吧——正面代表Mock,反面代表Stub,如果硬币直立,我允许你找一个Fake帮你干活。
如果觉得那个列表太难记,别怕。《JUnit Recipes》(Manning,2004)的作者J.?B. Rainsberger有一个简单的记忆规则,用于选择正确的测试替身类型:Stub管查询,Mock管操作。现在我们顺利地得到启发,知道什么时候该用哪种测试替身了,接下来看看如何使用它们。
3.3.2 准备、执行、断言
关于编码约定(convention),我要说几句。问题是各种标准太多了。幸运的是,当你构造单元测试时,存在一个大多数程序员都认为合理的、相当确定的实践。它叫做准备-执行-断言(Arrange-Act-Assert),这种组织测试的方式基本上是这样的,先准备用于测试的对象,然后触发执行,最后对输出进行断言。
代码清单3.8复制了代码清单3.7的测试,我们看它如何符合这种约定来组织测试方法。
注意我在三段代码之间增加空白的方式。这用来强调三段代码的不同角色。
测试的前五行是准备所要用到的协作对象。虽然其中我们只涉及Internet接口的一个Mock,但是在测试开头设置多个协作者的情况也很常见。然后是被测对象Translator——对它的实例化也是准备工作的一部分。
下一段代码中,我们调用translation(被测的翻译功能),最后,不论预期输出是直接输出还是造成的副作用,我们都对它进行断言。
给定-当-那么(Given,When,Then)
行为驱动开发运动所推广的词汇和结构与“准备-执行-断言”很像:给定(某个上下文),当(发生某些事情),那么(期望某些结果)。这个想法以更加直观的语言来指定预期行为,尽管“准备-执行-断言”更好记,但“给定-当-那么”更流畅,使人们更加自然地思考行为(而不是实现细节)。
这种结构相当普遍,它有助于使测试保持专注。如果感觉三部分中某一部分很“大”,那就是一个信号,表明测试可能试图做太多事情,需要更加专注。既然说到这话题,咱们就简单讨论一下测试应该专注什么。
3.3.3 检查行为,而非实现
人都会犯错误。模拟对象库新手常犯的一个错误是过度细致地对Mock设置期望。我指的是在测试中,对测试可能涉及的每个对象都做Mock,每个对象间的方法调用都严格指定。
是的,某种意义上,测试给予我们确定性,只要有任何变更它就会中断并报警。而这也是问题所在——即使是最小的变更,哪怕它与测试所要验证的不相关,也会中断测试。好比在一片口香糖上密密麻麻地敲了许多钉子,使之动弹不得。
这种测试的基本问题是缺乏专注。一个测试应当只测试一件事情,并好好地测试,清晰地沟通自己的意图。看着被测对象,你要问自己到底什么是想要验证的预期行为?至于实现细节,倒是并不需要钉在我们的测试中。
预期行为应该配置在Mock对象的期望中。应该寻求通过Stub或非严格Mock来提供实现细节,它们不介意交互从未发生或者发生多次。
检查行为,而非实现。当你掏出喜爱的模拟对象库时,你应该牢牢记住这一点。说到这里……
3.3.4 挑选你的工具
说到模拟对象库,Java程序员真是占了大便宜——有太多可以选择的。像我之前提到的,你几乎可以用任何先进的库来做同样的事情,但是它们在API方面还是有些细微的区别以及一些独特的功能,从而在满足某些特定方向和需求时能够一锤定音。
或许其中最独特的功能就是Mockito的打桩与验证分离。这得细说,接下来看个例子,我用Mockito重写了之前采用JMock的测试:
Mockito的API比JMock更简洁。除此之外,看起来差不多,是不是?是的,只是这个用Mockito写的测试仅仅对方法get()打桩——即使交互从未发生,它也会成功通过。如果我们真的希望验证Translator使用Internet的行为,我们就得增加一个对Mockito API的调用来进行检查:
模拟对象库API通常是个人喜好问题。但是Mockito在测试风格上有一个明显的优势,那就是主要依赖于打桩——在你的特定上下文中这可能是优势,也可能不是。测试代码每天都保持可读、简洁、可维护,这才是关键。这值得停下来权衡一下,明智地选择工具。
咱们再次借用J.?B.的话来明确JMock与Mockito在方式和适用条件方面的区别:
当我想要拯救遗留代码时,我选择Mockito。当我想要设计新功能时,我选择JMock。
JMock与Mockito不同的前提假设,使得两者擅长不同的任务。默认情况下,JMock认为测试替身(Mock)期望着客户不会在任何时候调用任何方法。如果你想放宽这个假设,你就得增加一个stub。另一方面,Mockito认为测试替身(也叫Mock)允许客户在任何时候调用任何方法。如果你想加强这个假设,那么你就得验证某个方法的调用。这就是区别所在。
不论你决定选择哪个库,我们的第三个即最后一个测试替身指南全都适用。
3.3.5 注入依赖
为了能够使用测试替身,你需要一种替换真实事物的方法。当涉及依赖时——为了测试目的而替换协作对象——我们的指南建议不要在同一个地方同时实例化和使用它们。在实践中,这意味着将这些对象另存为私有成员,或借助工厂方法来获取它们。
一旦你隔离开依赖,你就需要访问它。你可以用可见性修饰符来破坏封装——将私有(private)内容变成公开(public)或者包级私有(package private)——或使用反射API来将测试替身分配给私有字段。那种方式很快就会变得丑陋。更好的选择是采用依赖注入,从外部将依赖传递给对象,通常使用构造函数注入,正如在Translator例子中那样。
我们对于测试替身说得够多了,我渴望进行第二部分了,接下来对本章学到的东西做一个回顾吧。