首页 > 技术文章 > 测试替身的类型

hellohello 2018-05-02 23:34 原文

来源于《有效的单元测试》系列文章3.2 测试替身的类型:https://yq.aliyun.com/articles/118921

3.2 测试替身的类型

你见过了使用测试替身的各种原因,我们也暗示了有多种测试替身可供选择。我们来仔细看看那些类型吧。图3.3展示了这把大伞下的四种对象。
image

既然我们已经制定了测试替身的分类,现在就来认识一下它们,并了解相互的区别,以及运用它们的典型目的。我们先从最简单的开始。

3.2.1 测试桩通常是短小的

我这样来定义它:桩(名词),截断的或非常短的物体。
这衍生出测试桩的精确定义。测试桩(简称桩或Stub)的目的是用最简单的可能实现来代替真实实现。最基本的实现例子就是一个对象的所有方法都只有一行,且它们各自返回一个适当的默认值。
假如你负责的代码应当对自己的操作生成一段审计日志,并通过叫做Logger的接口写入远程日志服务器。假如Logger接口仅仅定义了一个方法来产生此类日志,那么Logger接口的桩看起来是这样:
image

有没有注意到log()方法其实什么都没做?这是桩的典型例子——什么都不做。毕竟,你正是对真实Logger实现打桩,因为你在测试时完全不在乎日志,那么又何必真写日志呢?但是有时候什么都不做也不行。例如,如果Logger接口还定义了一个方法来确定当前设置的日志级别(Log Level),那么桩实现看起来可能是这样:
image

我们在这个类中硬编码了getLogLevel()方法,它总是返回LogLevel.WARN。有没有搞错?大部分情况下这绝对没问题。毕竟,我们有三个充分的理由来使用测试桩代替真实Logger实现:
1.?我们的测试不关心被测代码所写的日志。
2.?我们没有运行日志服务器,所以测试会悲剧地失败。
3.?我们也不希望测试套件在控制台中输出大量字节(更别提将所有数据写入文件了)。
简而言之,Logger桩实现完美地满足了我们的需要。
有时候,简单的硬编码返回语句和一堆空的void方法还不够。有时候你至少需要填充一些行为,而有时候你需要测试替身根据收到的消息种类来表现出不同的行为。这些情况下,你会借助伪造对象。

3.2.2 伪造对象做事不产生副作用

比起Stub,伪造对象(简称Fake)是一种更加复杂的测试替身。Stub可以返回硬编码值,而每个测试可能需要有差异地实例化来返回不同值,以模拟不同的场景。Fake更像是真实事物的简单版本,优化地伪造真实事物的行为,但是没有副作用或使用真实事物的其他后果。
持久化对象是采用Fake的典型例子。假设应用程序架构是这样的:一些存储对象提供持久化服务,它们知道如何存储和查找指定的对象类型。这种存储对象可能提供的API如下:
image

对于使用存储对象的应用程序,如果没有这种测试替身,测试全都将试图访问真实的数据库。要是对UserRepository接口打桩,令其精确地返回测试所需,你就会感觉好一些。但是模拟更复杂的场景肯定会越发复杂。另一方面,由于UserRepository接口足够简单,以至于你可以实现一个愚蠢而简单的内存数据库,它只提供基本的数据类型。代码清单3.4提供了一个例子。
image

用这种另类实现来替换真实事物的优点在于,它像只鸭子那样嘎嘎叫,还能摇摆,但它摇摆得比真鸭子要快——即使每次查找一个User时都循环一个包含50个条目的列表。
测试桩和伪造对象往往是救命稻草,你可以在测试时用它们替换掉缓慢的真实事物,以及鞭长莫及的依赖。然而,这两种基本的测试替身不总是够用。有时你发现自己面对一堵墙,希望自己能像千里眼一样看透它——为了验证代码行为符合预期。那些情况下,你可能会求助于测试间谍。

3.2.3 测试间谍偷取秘密

你如何测试下列方法?
image

大多数人会说,把这个那个传进去,然后检查返回值是什么的。那可能没问题。毕竟正确的返回值是你最关心的。那么,下列方法又如何测试?
image

这里并没有返回值可以用来断言。这个方法所做的事情是接收一个列表和一个谓词(predicate),过滤列表中不满足谓词的条目。换句话说,验证这个方法正常工作的唯一方式就是事后检查列表。这就像警察卧底,然后汇报她看到的一切。通常你不用测试替身也能做到这一点。这个例子中你可以询问List对象,看它是否包含你所期望的条目。
至于测试替身——我们正在讨论的测试间谍(简称Spy)——的方便之处在于,当没有对象作为参数传入时,通过它们的API也能揭示你想要了解的知识。代码清单3.5显示了这样一个例子。
image

我们先来看看上述代码清单中的场景。被测对象是一个分布式的日志对象DLog,代表了一组DLogTarget。当向DLog写入时,你应该向所有DLogTarget写入相同的消息。从测试的角度来看,事情有点尴尬,你无法知道指定的消息是否被写入,因为DLogTarget接口只定义了一个方法write(),而且DLogTarget、ConsoleTarget和RemoteTarget的真实实现也都没有提供任何方法。
测试间谍登场了。代码清单3.6展示了一个精明的程序员如何鞭打他的女特工去干活。
image
image

  这就是测试间谍的一切。像其他测试替身一样,你将它们传入。然后你令测试间谍记录已发送的消息,并让测试询问测试间谍是否收到指定消息。干得漂亮!
简而言之,测试间谍是一种测试替身,它用于记录过去发生的情况,这样测试在事后就知道所发生的一切。有时我们进一步利用这个概念,于是测试间谍就变成了全能的模拟对象。如果测试间谍像个卧底警察,那么模拟对象就像渗入暴民的远程控制机器人。这可能需要一些解释……

3.2.4 模拟对象反对惊喜

  模拟对象(简称Mock)是特殊的Spy。它是一个在特定情景下可配置行为的对象。例如,UserRepository接口的模拟对象可能被告之:当带着参数123调用findById()时要返回null,而当带着参数124调用findById()时要返回User的一个实例。在这一点上,我们主要讨论的是根据参数来对特定的方法调用打桩。
如果一旦任何意外发生时Mock就立即使测试失败,Mock就能够变得更加精确。例如,假设我们告诉了模拟对象如何应对带着123或124的findById()调用,它就会严格按照指令工作。对于任何其他的调用——不论是调用不同的方法或者带着另外的参数调用findById()——Mock就会抛出异常,直接使测试失败。同样,如果findById()被调用太多次,Mock就会抱怨——除非我们告诉它允许调用任意次数——如果预期的调用没发生,Mock也会抱怨。
包括JMock、Mockito和EasyMock在内的模拟对象库已经是成熟的工具了,崇尚测试的程序员可以借助它们获得力量。每个库都有自己的行事风格,但基本上你可以用它们中任何一个来完成所有的工作。
这并非模拟对象库的全面教程,但是我们迅速看看代码清单3.7中的例子,它展示了这种库的具体用法。这里我们使用JMock,因为我碰巧有个项目正在使用JMock。
image
image

  在这样一小段测试代码中,这个例子展示了许多模拟对象库用法的典型构造。首先,我们告诉库要为指定接口创建一个模拟对象。
在context.checking()中看似笨拙的代码块其实是测试在指导模拟的Internet,告诉它应该期待哪些交互,以及如何应对这些交互。这种情况下,我们预期测试会带着包含"langpair=en%7Cfi"字符串的参数调用get()方法一次,对此,mock应当返回指定字符串。
最终,我们将Mock传给被测的Translator对象,执行Translator,然后断言Translator为我们的场景提供了正确的翻译。
然而,这并非我们的全部断言。如前所述,Mock可以严格地判断已经发生的预期交互。在模拟Internet的例子中,Mock严格地断言它确实收到了一次带有指定子字符串参数的get()方法调用。

推荐阅读