我什么时候该嘲笑你?

我对模仿和假对象有一个基本的理解,但是我不确定何时何地使用模仿-特别是当它适用于这个场景 这里时。

72111 次浏览

当您试图测试的代码单元中有一个依赖项时,您应该模拟一个对象,而这个依赖项需要“仅此而已”。

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

关于这个主题的一个很棒的播客可以找到 给你

单元测试应该通过单个方法测试单个代码路径。当一个方法的执行从该方法外部传递到另一个对象,然后再传递回来时,您就有了一个依赖项。

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

如果您的依赖是错误的,您的测试可能会受到这样的影响,以返回一个假阳性。例如,您可能会向依赖项传递一个意外的 null,而且依赖项可能不会像文档中记录的那样抛出 null。您的测试没有遇到它应该遇到的 null 参数异常,测试通过了。

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

模拟代替了依赖关系。您可以设置对依赖对象的调用的期望值,设置它应该给您的精确返回值,以执行所需的测试,以及/或抛出哪些异常,以便您可以测试异常处理代码。通过这种方式,您可以很容易地测试有问题的单元。

DR: 嘲笑单元测试触及的每个依赖项。

当您希望在测试中的类和特定接口之间使用 测试相互作用时,Mock 对象非常有用。

例如,我们希望测试方法 sendInvitations(MailServer mailServer)只调用 MailServer.createMessage()一次,也只调用 MailServer.sendMessage(m)一次,在 MailServer接口上不调用其他方法。这是我们可以使用模拟对象的时候。

使用模拟对象,我们可以传递 MailServer接口的模拟实现,而不是传递实际的 MailServerImpl或测试 TestMailServer。在传递模拟 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。如果不可能,改变实际的类使之成为可能。不能在测试中使用类通常会指出类的一些问题。修复这些问题是一个双赢的局面——你修复了这个类,你有了一个更简单的测试。另一方面,不修复它和使用 mock 是一个双输——你没有修复真正的类,你有更复杂、更不可读的测试,阻碍了进一步的重构。
  • 尝试创建接口的简单测试实现,而不是在每个测试中对其进行模拟,并在所有测试中使用此测试类。创建不执行任何操作的 TestPdfFormatter。通过这种方式,您可以对所有测试进行一次性更改,并且您的测试不会因为训练存根的冗长设置而变得杂乱无章。

总之,模拟对象有它们的用途,但是如果不小心使用,则使用 他们经常鼓励错误的实践,测试实现细节,阻碍重构,产生难以阅读和难以维护的测试

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

经验法则:

如果您正在测试的函数需要一个复杂的对象作为参数,并且简单地实例化这个对象(例如,如果它试图建立一个 TCP 连接)将是一个痛苦的过程,那么使用 mock。