我对模拟和伪造对象有基本的了解,但我不确定我对何时/何处使用模拟有感觉 - 特别是因为它适用于这种情况 这里.

有帮助吗?

解决方案

单元测试应该通过单个方法测试单个代码路径。当一个方法的执行传递到该方法之外、进入另一个对象并再次返回时,就产生了依赖关系。

当您使用实际依赖项测试该代码路径时,您不是在进行单元测试;而是在进行单元测试。你正在集成测试。虽然这很好而且必要,但它不是单元测试。

如果您的依赖有问题,您的测试可能会受到影响而返回误报。例如,您可以向依赖项传递一个意外的 null,并且依赖项可能不会像文档中那样抛出 null。您的测试没有遇到应有的空参数异常,并且测试通过。

此外,您可能会发现很难(如果不是不可能的话)可靠地让依赖对象在测试期间准确地返回您想要的内容。这还包括在测试中抛出预期的异常。

模拟取代了这种依赖关系。您可以设置对依赖对象的调用的期望,设置它应该为您提供执行所需测试的确切返回值,和/或抛出哪些异常,以便您可以测试异常处理代码。通过这种方式,您可以轻松测试相关单元。

长话短说:模拟单元测试涉及的每个依赖项。

其他提示

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

例如,我们要测试该方法 sendInvitations(MailServer mailServer) 来电 MailServer.createMessage() 恰好一次,并且还调用 MailServer.sendMessage(m) 恰好一次,并且没有调用其他方法 MailServer 界面。这是我们可以使用模拟对象的时候。

使用模拟对象,而不是传递真实的对象 MailServerImpl, ,或测试 TestMailServer, ,我们可以传递一个模拟实现 MailServer 界面。在我们通过模拟之前 MailServer, ,我们“训练”它,以便它知道期望调用什么方法以及返回什么返回值。最后,模拟对象断言所有预期的方法都按预期调用。

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

模拟缺点

如果您有一个模拟框架,您可能会想使用模拟对象 每次 您需要将接口传递给测试下的类。这样你最终会 即使没有必要也测试交互. 。不幸的是,不需要的(意外的)交互测试是不好的,因为这样你测试的是特定的需求是否以特定的方式实现,而不是该实现产生了所需的结果。

这是伪代码的示例。假设我们创建了一个 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 那什么也没做。这样,您可以为所有测试更改一次,并且您的测试不会因训练存根的冗长设置而混乱。

总而言之,mock对象有它的用处,但是如果不小心使用的话, 他们经常鼓励不良实践,测试实现细节,阻碍重构并产生难以阅读和难以维护的测试.

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

经验法则:

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

当您尝试测试的代码单元中存在依赖关系并且需要“正是如此”时,您应该模拟一个对象。

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

可以找到有关该主题的精彩播客 这里

许可以下: CC-BY-SA归因
不隶属于 StackOverflow
scroll top