我什么时候应该嘲笑?


137

我有模仿和假冒对象的一个基本的了解,但我不知道我有一个关于何时/何用嘲讽的感觉-特别是因为它也适用于这种情况在这里


我建议仅模拟进程外依赖性,并且仅模拟其中的,外部可观察到的交互(SMTP服务器,消息总线等)。不要模拟数据库,这是一个实现细节。在此处了解更多信息:enterprisecraftsmanship.com/posts/when-to-mock
弗拉基米尔·弗拉基米尔

Answers:


121

单元测试应通过单一方法测试单一代码路径。当一个方法的执行从该方法之外传递到另一个对象,然后又返回时,您将具有依赖项。

当您使用实际的依赖关系测试该代码路径时,就不是单元测试;您正在进行集成测试。尽管这是很好且必要的,但这不是单元测试。

如果您的依赖项存在错误,则可能会以返回假阳性的方式影响您的测试。例如,您可能将依赖项传递给意外的null,并且依赖项可能不会像记录中所述那样抛出null。您的测试不会发出应有的null参数异常,并且测试通过。

此外,您可能会发现,即使不是不可能,也很难可靠地使依赖对象返回测试期间要返回的精确值。这还包括在测试中引发预期的异常。

模拟代替了该依赖关系。您可以设置对依赖对象的调用的期望值,设置应该给您的确切返回值以执行所需的测试,和/或抛出什么异常,以便可以测试异常处理代码。这样,您可以轻松测试所涉及的单元。

TL; DR:模拟单元测试所涉及的每个依赖项。


164
这个答案太激进了。只要单元测试都属于同一个内聚单元,单元测试就可以而且应该采用多种方法。否则,将需要太多的模拟/伪造,导致复杂而脆弱的测试。只有不真正属于被测单元的依赖项才可以通过模拟来替换。
罗杰里奥(Rogério)2010年

10
这个答案也太乐观了。如果它结合了@Jan的模拟对象的缺点,那就更好了。
杰夫·阿克塞尔罗德

1
这是否更多是为了注入测试依赖项而不是专门为模拟注入参数?您几乎可以在答案中将“模拟”替换为“存根”。我同意您应该嘲笑或存根重要的依赖关系。我已经看到了很多重模拟的代码,这些代码基本上最终重新实现了部分被模拟的对象。模拟当然不是灵丹妙药。
Draemon 2012年

2
模拟单元测试所涉及的每个依赖项。这说明了一切。
Teoman shipahi 2015年

2
TL; DR:模拟单元测试所涉及的每个依赖项。-Mockito本身说,这实际上不是一个好方法-不要嘲笑一切。(已
投票

167

当您要测试被测类和特定接口之间的交互时,模拟对象很有用。

例如,我们要测试该方法仅sendInvitations(MailServer mailServer)调用MailServer.createMessage()一次,并且也仅调用MailServer.sendMessage(m)一次,并且MailServer接口上没有其他方法被调用。这是我们可以使用模拟对象的时候。

使用模拟对象,可以通过接口的模拟实现,而无需通过real MailServerImpl或test 。在传递模拟之前,我们需要对它进行“训练”,以使它知道要调用的方法以及要返回的返回值。最后,模拟对象断言,所有预期的方法均按预期方式调用。TestMailServerMailServerMailServer

从理论上讲,这听起来不错,但也有一些缺点。

模拟缺点

如果您有一个模拟框架,那么每次需要将接口传递给被测类,您都倾向于使用模拟对象。这样,即使没有必要,您也可以测试交互。不幸的是,对交互进行不必要的(偶然的)测试是很糟糕的,因为那样您就在测试以某种特定方式实现了特定需求,而不是实现产生了所需的结果。

这是伪代码示例。假设我们已经创建了一个MySorter类并想要对其进行测试:

// the correct way of testing
testSort() {
    testList = [1, 7, 3, 8, 2] 
    MySorter.sort(testList)

    assert testList equals [1, 2, 3, 7, 8]
}


// incorrect, testing implementation
testSort() {
    testList = [1, 7, 3, 8, 2] 
    MySorter.sort(testList)

    assert that compare(1, 2) was called once 
    assert that compare(1, 3) was not called 
    assert that compare(2, 3) was called once 
    ....
}

(在此示例中,我们假设它不是要测试的特定排序算法,例如快速排序;在这种情况下,后者测试实际上是有效的。)

在这样一个极端的例子中,很明显后一个例子为什么是错误的。当我们更改的实现时MySorter,第一个测试可以很好地确保我们仍然正确排序,这是测试的重点-它们使我们能够安全地更改代码。另一方面,后一种测试总是会中断,并且会产生积极的危害。它阻碍了重构。

嘲弄存根

模拟框架通常也允许不太严格的用法,在这种情况下,我们不必确切指定应调用方法的次数和期望的参数。它们允许创建用作存根的模拟对象。

假设我们有一个sendInvitations(PdfFormatter pdfFormatter, MailServer mailServer)要测试的方法。该PdfFormatter对象可用于创建邀请。这是测试:

testInvitations() {
   // train as stub
   pdfFormatter = create mock of PdfFormatter
   let pdfFormatter.getCanvasWidth() returns 100
   let pdfFormatter.getCanvasHeight() returns 300
   let pdfFormatter.addText(x, y, text) returns true 
   let pdfFormatter.drawLine(line) does nothing

   // train as mock
   mailServer = create mock of MailServer
   expect mailServer.sendMail() called exactly once

   // do the test
   sendInvitations(pdfFormatter, mailServer)

   assert that all pdfFormatter expectations are met
   assert that all mailServer expectations are met
}

在此示例中,我们并不真正在意这个PdfFormatter对象,因此我们只是训练它静默地接受任何调用,并为此时sendInvitation()恰好所有调用的方法返回一些合理的固定返回值。我们是如何精确地得出此训练方法清单的?我们只运行测试并不断添加方法,直到测试通过。注意,我们训练了存根以对方法做出响应,而又不知道为什么需要调用它,我们仅添加了测试所抱怨的所有内容。我们很高兴,测试通过了。

但是,当我们更改sendInvitations()sendInvitations()使用其他类创建更多精美的pdf时,会发生什么呢?我们的测试突然失败了,因为现在PdfFormatter调用了更多的方法,而我们没有训练存根来期望它们。通常,不仅是一个测试在这种情况下失败了,而且是直接或间接使用该sendInvitations()方法的任何测试。我们必须通过增加培训来修复所有这些测试。还要注意,我们无法删除不再需要的方法,因为我们不知道不需要哪些方法。同样,它阻碍了重构。

而且,测试的可读性非常糟糕,因为有我们想要的代码,所以我们没有编写很多代码,但是因为我们不得不这样做。不是我们想要在那里的代码。使用模拟对象的测试看起来非常复杂,通常很难阅读。测试应该帮助读者理解,应该如何使用测试中的类,因此它们应该简单明了。如果它们不可读,那么没人会去维护它们。实际上,删除它们比维护它们容易。

如何解决?容易:

  • 尽可能尝试使用真实的类而不是模拟。使用真实的PdfFormatterImpl。如果不可能,请更改实际类以使其成为可能。无法在测试中使用类通常会指出该类的一些问题。解决问题是双赢的局面-您修复了班级,并且进行了更简单的测试。另一方面,不修复它并使用模拟程序是双赢的情况-您没有修复真实的类,并且您拥有更复杂,可读性更差的测试,从而阻碍了进一步的重构。
  • 尝试为接口创建一个简单的测试实现,而不是在每个测试中对其进行模拟,并在所有测试中使用此测试类。创建TestPdfFormatter什么都不做。这样一来,您可以为所有测试更改一次,并且不会因训练存根的冗长设置而使测试混乱。

总而言之,模拟对象有其用途,但是如果不小心使用它们它们通常会鼓励不良做法,测试实现细节,阻碍重构并产生难以阅读且难以维护的测试

有关模拟缺点的更多详细信息,另请参见模拟对象:缺点和用例


1
经过深思熟虑的答案,我大都同意。我要说的是,由于单元测试是白盒测试,因此在更改实现以发送更高级的PDF时必须更改测试可能不会造成不合理的负担。有时,模拟可能是快速实现存根的有用方法,而不是使用大量样板文件。在实践中,似乎并没有将它们的使用限制在这些简单的情况下。
Draemon

1
模拟的全部目的不是在于您的测试是一致的吗,您不必担心在每次运行测试并获得一致的测试结果时,对其他对象可能会不断更改其实现的对象进行模拟吗?
PositiveGuy

1
非常好和相关的观点(尤其是关于测试的脆弱性)。在我年轻的时候,我经常使用模拟游戏,但是现在,我认为大量依赖模拟游戏的单元测试可能是一次性的,而更多地侧重于集成测试(使用实际组件)
Kemoda 2013年

6
“不能在测试中使用类通常会指出该类存在一些问题。” 如果该类是服务(例如,对数据库的访问或对Web服务的代理),则应将其视为外部依赖项,并进行嘲笑/存根
Michael Freidgeim 2013年

1
但是,当我们更改sendInvitations()之后会发生什么呢?如果被测代码被修改,它将不再保证先前的合同,因此必须失败。 通常,在这种情况下失败的不仅是一项测试。在这种情况下,代码不是干净实现的。依赖性方法调用的验证应仅测试一次(在适当的单元测试中)。所有其他类将仅使用模拟实例。因此,我看不到将集成与单元测试相结合的任何好处。
Christopher Will

55

经验法则:

如果您要测试的函数需要一个复杂的对象作为参数,并且简单地实例化该对象(例如,如果它试图建立TCP连接)将很痛苦,请使用模拟。


4

当您要测试的代码单元中具有依赖性时,应该模拟一个对象,该对象必须“正好如此”。

例如,当您尝试在代码单元中测试某些逻辑但您需要从另一个对象获取某些东西时,从此依赖项返回的内容可能会影响您要测试的内容-模拟该对象。

可以在这里找到有关该主题的出色播客


现在,链接将路由到当前剧集,而不是预期的剧集。预定的播客是hanselminutes.com/32/mock-objects吗?
C Perkins
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.