模拟对象的目的是什么?


167

我是单元测试的新手,并且不断听到“模拟对象”一词泛滥的声音。用外行的话,有人可以解释什么是模拟对象,以及在编写单元测试时它们通常用于什么目的?


12
它们是一种工具,可以通过您不需要眼前的问题的灵活性来大规模地过度设计事物。
dsimcha's

Answers:


360

既然您说您是单元测试的新手,并要求使用“外行的术语”来模拟对象,所以我将尝试一个外行的示例。

单元测试

想象一下该系统的单元测试:

cook <- waiter <- customer

通常很容易想到测试像这样的低级组件cook

cook <- test driver

测试驾驶员只需订购不同的菜肴,并验证厨师是否为每个订单都返回了正确的菜肴。

测试利用其他组件行为的中间组件(如服务员)更加困难。天真的测试人员可能会以测试库克组件的相同方式来测试服务员组件:

cook <- waiter <- test driver

测试驾驶员将订购不同的菜肴,并确保服务员退回正确的菜肴。不幸的是,这意味着对服务员组件的测试可能取决于烹饪组件的正确行为。如果厨师部件具有任何测试不友好的特性,例如不确定性行为(菜单包括厨师给菜带来的惊喜),很多依赖项(如果没有整个员工都无法烹饪),则这种依赖关系会更糟。资源(某些菜肴需要昂贵的食材或需要一个小时才能烹饪)。

由于这是服务员测试,因此理想情况下,我们只想测试服务员,而不是厨师。具体来说,我们要确保服务员正确地将顾客的订单传达给厨师,并正确地将厨师的食物传递给顾客。

单元测试意味着独立地测试单元,因此更好的方法是使用Fowler所谓的测试双打(假人,存根,假货,假人)来隔离被测组件(服务员

    -----------------------
   |                       |
   v                       |
test cook <- waiter <- test driver

在这里,测试厨师与测试驱动程序“纠缠不清”。理想地,设计被测系统,以便可以轻松地替换(注入)测试厨师以与服务员一起工作,而无需更改生产代码(例如,无需更改服务员代码)。

模拟对象

现在,可以通过不同方式实现测试厨师(测试两倍):

  • 假厨师-用冷冻晚餐和微波炉假装做饭的人,
  • 存根厨师-无论您订购什么,总能给您热狗的热狗供应商,或者
  • 模拟厨师-剧本里伪装成厨师的剧本,是一个秘密警察。

请参阅Fowler的文章,以了解有关假货,存根,模拟和假人的更多详细信息,但现在,让我们关注模拟厨师。

    -----------------------
   |                       |
   v                       |
mock cook <- waiter <- test driver

对服务员组件进行单元测试的很大一部分集中在服务员如何与Cook组件进行交互上。基于模拟的方法着重于完全指定正确的交互作用,并检测何时出现问题。

模拟对象预先知道在测试期间会发生什么(例如,将调用其哪种方法调用等),模拟对象知道其应如何反应(例如,提供什么返回值)。模拟将指示实际发生的情况与预期发生的情况是否不同。可以从头开始为每个测试用例创建一个自定义的模拟对象,以执行该测试用例的预期行为,但是模拟框架努力使此类行为规范可以在测试用例中直接清楚地表明。

围绕基于模拟的测试的对话可能看起来像这样:

试车手,以模拟厨师期待一个热狗秩序,给他回应这个虚拟热狗

测试驱动程序(冒充客户)服务员我想一个热狗请
服务员嘲笑煮1个热狗请
模仿厨师服务员为了达到:1个热狗准备(给假热狗到服务员)
服务员,以试车手这是您的热狗(给假热狗测试驾驶员)

测试驱动程序:测试成功!

但是由于我们的服务员是新来的,因此可能会发生以下情况:

试车手,以模拟厨师期待一个热狗秩序,给他回应这个虚拟热狗

测试驱动程序(冒充客户)服务员我想一个热狗请
服务员嘲笑煮1个汉堡请
模仿厨师停止测试:有人告诉我,期待一个热狗订购!

测试驱动程序注意到问题:测试失败!-服务员改变了顺序

要么

试车手,以模拟厨师期待一个热狗秩序,给他回应这个虚拟热狗

测试驱动程序(冒充客户)服务员我想一个热狗请
服务员嘲笑煮1个热狗请
模仿厨师服务员为了达到:1个热狗准备(给假热狗到服务员)
服务员,以试车手这是您的炸薯条(以其他顺序提供炸薯条以测试驾驶员)

测试驾驶员注意到了意想不到的炸薯条:测试失败!服务员给了错误的菜

如果没有与此相反的基于存根的示例来比较,可能很难清楚地看到模拟对象和存根之间的区别,但是这个答案已经太长了:-)

还要注意,这是一个非常简单的示例,并且模拟框架允许对组件的预期行为进行一些相当复杂的规范,以支持全面的测试。有关模拟对象和模拟框架的资料很多,以获取更多信息。


12
这是一个很好的解释,但是您是否在某种程度上测试了waiter的实现?在您的情况下,这可能没问题,因为您正在检查它是否使用了正确的API,但是如果有不同的方法来做,而服务员可能选择其中一种,那该怎么办?我认为单元测试的重点是测试API,而不是实现。(当我读到有关嘲笑的内容时,我总是会问自己这个问题。)
davidtbernal 2010年

8
谢谢。我不能说我们是否在不查看(或定义)服务员规范的情况下测试“实现”。您可能会假设服务员可以自己做菜或在街上下订单,但我认为服务员的规格包括使用预定的厨师-毕竟,生产厨师是昂贵的美食厨师,我们d希望我们的服务员使用他。没有该规格,我想我必须得出结论,您是正确的-服务员可以按自己想要的顺序填写订单,以便“正确”。OTOH,没有规格,该测试是没有意义的。
Bert

8
没什么,您提出了一个很棒的观点,导致了白盒与黑盒单元测试这个奇妙的话题。我认为业界没有共识,即单元测试必须是黑盒而不是白盒(“测试API,而不是实现”)。我认为最好的单元测试可能需要将两者结合起来,以平衡测试的脆弱性和代码覆盖率以及测试用例的完整性。
Bert F

1
根据我的说法,这个答案不够技术。我想知道为什么在可以使用真实对象时应该使用模拟对象。
Niklas R.

1
很好的解释!谢谢!!@BertF
Bharath

28

模拟对象是代替真实对象的对象。在面向对象的编程中,模拟对象是模拟对象,它们以受控方式模拟实际对象的行为。

计算机程序员通常会创建一个模拟对象来测试其他对象的行为,就像汽车设计师使用碰撞测试假人来模拟人在车辆撞击中的动态行为一样。

http://en.wikipedia.org/wiki/Mock_object

模拟对象使您可以设置测试方案,而不会占用大量笨拙的资源,例如数据库。您可以在单元测试中使用模拟对象来模拟数据库,而不必调用数据库进行测试。这使您免去了为建立类中的单个方法而建立和拆除真实数据库的负担。

“模拟”一词有时会与“存根”互换使用。这两个词之间的差异在这里描述。 本质上,模拟是存根对象,它还包含对被测对象/方法的正确行为的期望(即“断言”)。

例如:

class OrderInteractionTester...
  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    Mock warehouse = mock(Warehouse.class);
    Mock mailer = mock(MailService.class);
    order.setMailer((MailService) mailer.proxy());

    mailer.expects(once()).method("send");
    warehouse.expects(once()).method("hasInventory")
      .withAnyArguments()
      .will(returnValue(false));

    order.fill((Warehouse) warehouse.proxy());
  }
}

请注意,已为warehousemailer模拟对象编程了预期的结果。


2
您提供的定义与“存根对象”没有什么不同,因此也不能解释什么是模拟对象。
布伦特·阿里亚斯

另一校正“单词‘模拟’有时错误地互换‘存根’使用”。
布伦特·阿里亚斯

@Myst:这两个词的用法不是通用的;这在作者之间有所不同。福勒是这样说的,而维基百科的文章也是这样说的。但是,请随时进行更改并删除您的否决票。:)
罗伯特·哈维

1
我同意罗伯特的看法:“模拟”一词的用法在整个行业中往往会有所不同,但是根据我的经验,没有固定的定义,只是它通常不是要测试的实际对象,而是在使用实际对象时便于测试的存在。对象或对象的所有部分将非常不方便且影响很小。
mkelley33'9

15

模拟对象是模拟真实对象行为的模拟对象。通常,您在以下情况下编写模拟对象:

  • 真实对象太复杂,无法将其合并到单元测试中(例如,网络通信中,您可以有一个模拟对象来模拟另一个对等对象)
  • 您的对象的结果是不确定的
  • 实际对象尚不可用

12

Mock对象是Test Double的一种。您正在使用模拟对象来测试和验证被测类与其他类的协议/交互。

通常,您将对“程序”或“记录”有某种期望:方法调用使您希望类对基础对象执行。

例如,假设我们正在测试一种服务方法来更新Widget中的字段。在您的体系结构中,有一个WidgetDAO可以处理数据库。与数据库的对话很慢,设置和清理之后很复杂,因此我们将模拟WidgetDao。

让我们考虑一下服务必须做什么:它应该从数据库中获取一个Widget,对其进行处理,然后再次保存。

因此,在具有伪模拟库的伪语言中,我们将具有以下内容:

Widget sampleWidget = new Widget();
WidgetDao mock = createMock(WidgetDao.class);
WidgetService svc = new WidgetService(mock);

// record expected calls on the dao
expect(mock.getById(id)).andReturn(sampleWidget);   
expect(mock.save(sampleWidget);

// turn the dao in replay mode
replay(mock);

svc.updateWidgetPrice(id,newPrice);

verify(mock);    // verify the expected calls were made
assertEquals(newPrice,sampleWidget.getPrice());

这样,我们可以轻松测试依赖于其他类的类的驱动开发。



9

在对计算机程序的某些部分进行单元测试时,理想情况下,您只想测试该特定部分的行为。

例如,从一个虚构的程序中查看下面的伪代码,该程序使用另一个程序来调用print某些东西:

If theUserIsFred then
    Call Printer(HelloFred)
Else
   Call Printer(YouAreNotFred)
End

如果要进行测试,则主要是要测试用户是否为Fred的部分。您真的不想测试Printer事物的一部分。那将是另一项考验。

是Mock对象进入的地方。它们假装为其他类型的东西。在这种情况下,您将使用Mock Printer,使其像真正的打印机一样工作,但不会进行打印之类的不便。


您可以使用不是Mocks的其他几种伪装对象。使Mocks Mocks成为主要对象的是,可以用行为和期望来配置它们。

预期会导致您的Mock在使用不当时引发错误。因此,在上面的示例中,您可能需要确保在“用户是Fred”测试案例中使用HelloFred调用了Printer。如果那没有发生,您的Mock会警告您。

Mocks中的行为意味着,例如,您的代码中的行为类似于:

If Call Printer(HelloFred) Returned SaidHello Then
    Do Something
End

现在,您想测试调用Printer并返回SaidHello时的代码行为,因此您可以设置Mock在用HelloFred调用时返回SaidHello。

关于此的一个很好的资源是Martin Fowlers发表Mocks Are n't Stubs


7

模拟和存根对象是单元测试的关键部分。事实上,他们很长的路要走,以确保您正在测试单位,而不是群体为单位。

简而言之,您可以使用存根来打破SUT对其他对象的依赖(System Under Test),并通过模拟来做到这一点,验证SUT是否对依赖项调用了某些方法/属性。这可以追溯到单元测试的基本原理,即测试应该易于阅读,快速并且不需要配置,这可能意味着使用所有实际类。

通常,您的测试中可以有多个存根,但是您只能有一个模拟。这是因为模拟的目的是验证行为,而您的测试只能测试一件事。

使用C#和Moq的简单方案:

public interface IInput {
  object Read();
}
public interface IOutput {
  void Write(object data);
}

class SUT {
  IInput input;
  IOutput output;

  public SUT (IInput input, IOutput output) {
    this.input = input;
    this.output = output;
  }

  void ReadAndWrite() { 
    var data = input.Read();
    output.Write(data);
  }
}

[TestMethod]
public void ReadAndWriteShouldWriteSameObjectAsRead() {
  //we want to verify that SUT writes to the output interface
  //input is a stub, since we don't record any expectations
  Mock<IInput> input = new Mock<IInput>();
  //output is a mock, because we want to verify some behavior on it.
  Mock<IOutput> output = new Mock<IOutput>();

  var data = new object();
  input.Setup(i=>i.Read()).Returns(data);

  var sut = new SUT(input.Object, output.Object);
  //calling verify on a mock object makes the object a mock, with respect to method being verified.
  output.Verify(o=>o.Write(data));
}

在上面的示例中,我使用了Moq来演示存根和模拟。Moq对两者使用相同的类- Mock<T>有点令人困惑。无论如何,在运行时,如果output.Write未将数据作为调用,则测试将失败parameter,而调用input.Read()失败不会使测试失败。


4

通过链接到“ Mocks Are n't Stubs ” 建议的另一个答案是,模拟是 “测试两倍”的一种形式,用于代替真实对象。使它们与其他形式的测试双打(例如存根对象)不同的原因在于,其他测试双打提供状态验证(和可选的模拟),而模拟提供行为验证(和可选的模拟)。

使用存根,您可以以任何顺序(甚至是反复地)在存根上调用多个方法,并确定存根是否已捕获您想要的值或状态,从而确定成功。相反,模拟对象期望以特定顺序甚至特定次数调用非常特定的函数。仅仅因为方法以不同的顺序或计数被调用,使用模拟对象的测试将被视为“失败”-即使测试结束时模拟对象的状态正确!

这样,通常认为模拟对象比桩对象更紧密地耦合到SUT代码。这可能是好事,也可能是坏事,具体取决于您要验证的内容。


3

使用模拟对象的部分要点是不必根据规范真正实现它们。他们只能给出虚拟响应。例如,如果您必须实现组件A和B,并且两者都相互“调用”(交互),那么只有在实现B之前,您才能测试A,反之亦然。在测试驱动的开发中,这是一个问题。所以你创建模拟(“假”)对象A和B,这是非常简单的,但他们给一些形式的回复时,他们正在与互动。这样,您可以使用B的模拟对象来实现和测试A。


1

对于php和phpunit,在phpunit documentaion中已作了很好的解释。看到这里 phpunit文档

用简单的话来说,模拟对象只是原始对象的伪对象,并返回其返回值,该返回值可在测试类中使用


0

这是单元测试的主要观点之一。是的,您正在尝试测试单个代码单元,并且测试结果不应与其他bean或对象行为相关。因此,您应该使用带有一些简化的相应响应的Mock对象来模拟它们。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.