如何使用 Mocks 避免重复逻辑

7
我有一个挑战,但是我还没有找到一个好的答案。我正在使用一个Mocking框架(在这个例子中是JMock)来使单元测试与数据库代码分离。我模拟了涉及数据库逻辑的类的访问,并使用DBUnit分别测试数据库类。
我的问题在于,我注意到一种模式,在多个地方概念上重复了逻辑。例如,我需要检测数据库中不存在的值,所以在这种情况下,我可能会从该方法返回null。因此,我有一个数据库访问类,它处理数据库交互,并适当地返回null。然后,我有业务逻辑类,从模拟中接收null,然后测试如果该值为null时应该如何操作。
现在,如果将来需要更改该行为并且不再返回null,比如说因为状态变得更加复杂,那么我需要返回一个对象,该对象报告值不存在和一些附加的数据库事实。
现在,如果我更改数据库类的行为,不再在该情况下返回null,则业务逻辑类仍然似乎可以正常工作,除非有人记得耦合或正确地遵循方法的用法,否则只有在QA中才能发现错误。
我觉得我错过了什么,必须有更好的方法来避免这种概念上的重复,或者至少将其进行测试,以便如果更改没有传播,则单元测试失败。
有什么建议吗?
更新:
让我尝试澄清我的问题。我在考虑代码随时间的演变,如何确保测试通过Mock测试的类和Mock所代表的实际类的实现之间的集成不会中断。
例如,我刚刚遇到了一个情况,其中一个最初创建的方法没有期望null值,因此这不是对真实对象的测试。然后,使用该类(通过模拟测试)的用户被增强以在某些情况下传递null作为参数。在集成时,这会导致错误,因为真实类没有测试null。现在,在最初构建这些类时,这不是一个大问题,因为您正在构建两端的测试,但是如果设计需要两个月后发展,当您倾向于忘记细节时,如何测试这两组对象之间的交互(通过模拟测试的对象与实际实现)?
潜在的问题似乎是重复的(违反DRY原则),期望实际上保存在两个地方,尽管关系是概念性的,但实际上没有重复代码。
没错,这正是我正在做的事情(除了在通过DBUnit测试并在测试期间与数据库交互的类中还有一些进一步的与DB交互,但这是同样的想法)。现在,假设我们需要修改数据库行为以使结果不同。使用模拟测试的测试将继续通过,除非1)有人记得或2)它在集成中出现故障。因此,数据库存储过程返回值(例如)在模拟测试数据中被复制。现在让我困扰的是重复的逻辑,这是DRY的微妙违反。可能只有这样(毕竟有集成测试的原因),但我感觉我缺少了什么。

【编辑开始提供赏金】

阅读与Aaron的互动可以达到问题的要点,但我真正寻求的是如何避免或管理明显的重复,并使真实类的行为变化显示为与模拟进行交互的单元测试中的某些断点。显然,这不会自动发生,但可能有一种正确设计场景的方法。

【编辑颁发赏金】

感谢所有花时间回答问题的人。获胜者教给我一些关于如何考虑在两个层之间传递数据的新思路,并首先得出了答案。


你的问题有点令人困惑。你可能需要编辑一下。 - Aaron Digulla
11个回答

5
你基本上在要求不可能的事情。你要求你的单元测试预测并通知你当你改变外部资源的行为时。如果没有编写测试来产生新的行为,它们怎么能知道呢?
你所描述的是添加一个全新的状态,必须进行测试-现在有一些对象从数据库中出来了,而不是空结果。你的测试套件怎么可能知道新的、随机对象下被测试对象的预期行为应该是什么?你需要编写一个新的测试。
模拟不是“表现不良”,正如你所评论的那样。模拟就是按照你设置的方式进行操作。规范的改变对模拟没有任何影响。这种情况下唯一的问题是实施更改的人忘记更新单元测试。我实际上不太确定你为什么认为存在重复的关注点。
添加一些新的返回结果到系统的程序员负责添加处理此情况的单元测试。如果代码也100%确定现在不可能返回空值,那么他也可以删除旧的单元测试。但为什么要这样做呢?单元测试正确地描述了在接收到空结果时被测试对象的行为。如果您将系统的后端更改为某个新的返回空值的数据库,会发生什么?如果规范再次更改返回null怎么办?您可能还是要保留测试,因为就您的对象而言,它确实可以从外部资源中获得任何返回值,并且应该优雅地处理每种可能的情况。
模拟的整个目的是将测试与真实资源解耦。它不会自动防止引入系统中的错误。如果你的单元测试准确地描述了接收到空值时的行为,那太好了!但是这个测试不应该有任何其他状态的知识,当然也不应该以某种方式被告知外部资源将不再发送null。
如果你正在进行正确、松散耦合的设计,你的系统可以有任何你能想象的后端。你不应该写测试时只考虑一个外部资源。听起来如果你添加一些使用真实数据库的集成测试,你可能会更开心,从而消除模拟层。对于构建或健全/烟雾测试,这总是一个很好的主意,但通常对日常开发来说是障碍。

感谢您提供详细的答案,您可能是正确的,我确实在要求不可能的事情,或者更正式地说,“这就是集成测试的目的。” 但是我的问题的关键并不是“某些外部资源”发生了变化,而是设计的某个部分(即演变)由于重构而发生了变化,但被模拟的部分对于单元测试来说太重了,因此不能将其全部一起测试。 - Yishai
当你写下“模拟的整个目的是将测试与真实资源解耦”的时候,我的问题来自于这样一种模拟的观点:“你只模拟你拥有的类型”http://www.mockobjects.com/2008/11/only-mock-types-you-own-revisited.html - Yishai
我完全同意这一点,但我认为你在概念上试图将一个链接强加到你的模拟中,这恰恰与它们的设计初衷相反 ;) - womp

4
您没有遗漏任何内容。这是使用模拟对象进行单元测试的一个弱点。您似乎已经将单元测试分解成了合理大小的单元。这是一件好事情;通常会发现人们在“单元”测试中测试得太多。
不幸的是,当您在这个粒度级别上进行测试时,您的单元测试并不涵盖协作对象之间的交互。您需要进行一些集成测试或功能测试来覆盖这一点。我真的不知道比这更好的答案了。
有时,在您的单元测试中使用真实的协作者而不是模拟是可行的。例如,如果您正在对数据访问对象进行单元测试,则在单元测试中使用真实的域对象而不是模拟通常很容易设置,并且执行效果也很好。反过来通常不成立--数据访问对象通常需要数据库连接、文件或网络连接,并且相当复杂和耗时,使用真实的数据对象来单元测试您的域对象将把需要微秒级的单元测试变成需要数百或数千毫秒的测试。
因此,总结一下:
1.编写一些集成/功能测试以捕获协作对象的问题
2.不必总是模拟协作者--根据您的判断使用最佳实践

2
您的数据库抽象层使用null表示“未找到结果”。忽略将null传递给对象之间的不良影响,您的测试不应该使用null文字来测试未找到任何内容时会发生什么。相反,使用常量或test data builder,这样您的测试只涉及对象之间传递的信息,而不是如何表示这些信息。然后,如果您需要更改数据库层表示“未找到结果”(或您的测试所依赖的任何其他信息)的方式,则只需在测试中更改一个地方即可。

这是一个很好的建议,用一种方式来抽象化数据表示,使模拟和真实类数据构建相结合。我必须尝试一下,看看它解决了多少问题。 - Yishai

2

单元测试无法告诉你一个方法突然拥有更小的可能结果集。这就是代码覆盖率的作用:它会告诉你哪些代码不再被执行。这反过来会导致在应用程序层中发现死代码。

[编辑] 基于评论:模拟必须什么也不做,只允许实例化被测试的类并允许收集额外的信息。特别是,它绝不能影响您想要测试的结果。

[编辑2] 模拟数据库意味着您不关心DB驱动程序是否有效。您想知道的是您的代码是否可以正确解释由DB返回的数据。此外,这也是测试您的错误处理是否正确的唯一方法,因为您无法告诉真正的DB驱动程序“当您看到此SQL时,请抛出此错误。”这只能通过模拟实现。

我同意,需要一些时间来适应。以下是我的做法:

  • 我有一些测试来检查SQL是否有效。每个SQL都针对静态测试DB执行一次,我验证返回的数据是否符合预期。

  • 所有其他测试都使用模拟DB连接器,该连接器返回预定义的结果。我喜欢通过运行代码来获取这些结果,记录主键。然后,我编写一个工具,将这些主键作为输入,将Java代码和模拟输出到System.out。这样,我可以非常快速地创建新的测试用例,并且测试用例将反映“真相”。

    更好的是,我可以通过再次运行旧ID和我的工具来重新创建旧测试(当DB更改时)


感谢您的回答。就我所看到的情况,该代码已全部涵盖在变更范围内。只有少量未涵盖的代码,实际类被注入以代替模拟类,但这没有改变。模拟类的行为不当是因为它设置了一个现实类无法满足的期望。 - Yishai
感谢您的编辑,但我不太明白。如果我模拟一个数据库调用,那么模拟必须返回测试数据。这怎么可能不会影响测试结果呢? - Yishai

1

这是我理解你的问题的方式:

您正在使用实体的模拟对象使用JMock测试应用程序的业务层。您还使用DBUnit测试DAO层(应用程序与数据库之间的接口),并传递填充有已知值集的实体对象的真实副本。由于您正在使用2种不同的准备测试对象的方法,因此您的代码违反了DRY,并且在代码更改时可能会导致测试与现实不同步。

Folwer说...

虽然不完全相同,但它确实让我想起了Martin Fowler的Mocks Aren't Stubs文章。我认为JMock路线是模拟主义者的方式,而“真实对象”路线是执行测试的经典主义者的方式。

在解决这个问题时尽可能地DRY的一种方法是更多地成为经典主义者而不是模拟主义者。也许您可以妥协并在测试中使用实际的bean对象的副本。

用户使用Makers避免重复

在一个项目中,我们所做的是为每个业务对象创建一个制造者(Maker)。该制造者包含静态方法,可以构建给定实体对象的副本,并填充已知值。然后,无论您需要哪种类型的对象,都可以调用该对象的制造者,并获得带有已知值的副本,以供测试使用。如果该对象具有子对象,则您的制造者将调用子对象的制造者,以便从上到下构建它,并且您将获得所需的完整对象图的尽可能多的部分。您可以将这些制造者对象用于所有测试--在测试DAO层时将它们传递给DB,以及在测试业务服务时将它们传递给您的服务调用。由于制造者是可重用的,因此这是一种相当DRY的方法。
但是,您仍然需要使用JMock来模拟测试服务层时的DAO层。如果您的服务调用DAO,则应确保注入模拟而不是实际DAO。但是您仍然可以像往常一样使用您的制造者--在设置期望时,只需确保您的模拟DAO使用相关实体对象的制造者返回预期结果即可。这样,我们仍然没有违反DRY。
良好编写的测试将在代码更改时通知您。

避免代码随时间变化而出现问题的最终建议是,始终编写一个测试来处理空输入。假设在您首次创建方法时,不接受空值。您应该编写一个测试来验证如果使用null,则会抛出异常。如果以后空值变得可接受,您的应用程序代码可能会更改,以新的方式处理空值,并且不再抛出异常。当发生这种情况时,您的测试将开始失败,并且您将收到“警告”,表明事物不同步。


1

你需要确定返回 null 是否是外部 API 的一个意图部分,还是实现细节。

单元测试不应关心实现细节。

如果它是你预期的外部 API 的一部分,那么由于你的更改可能会破坏客户端,这自然也应该破坏单元测试。

从外部 POV 来看,这个东西返回 NULL 是否有意义,还是因为在客户端中可以直接做出关于此 NULL 意义的假设而方便?NULL 应该表示无效/无/nada/不可用,没有其他含义。

如果你计划稍后精细化此条件,则应将 NULL 检查包装到返回信息异常、枚举或显式命名的布尔值中。

编写单元测试的一个挑战是即使是编写的第一个单元测试也应反映最终产品中的完整 API。你需要想象完整的 API,然后针对 THAT 进行编程。

此外,你需要在单元测试代码中保持与生产代码相同的纪律,避免重复和特性嫉妒等问题。


1

我想把问题缩小到它的核心。

问题

当然,大多数更改都会被测试捕捉到。
但是有一部分场景,你的测试不会失败,尽管它应该:

在编写代码时,您多次使用自己的方法。您获得方法定义和使用之间的1:n关系。每个使用该方法的类都将在相应的测试中使用其模拟。因此,模拟也被使用n次。

曾经期望您的方法结果永远不会为null。在更改此设置后,您可能会记得修复相应的测试。到目前为止一切顺利。

您运行测试-全部通过

但是随着时间的推移,您忘记了一些东西...模拟从来没有返回过null。因此,使用模拟的n个类的n个测试没有测试null

您的QA将失败-尽管您的测试未失败。

显然,您必须修改其他测试。但是没有失败可以使用。因此,您需要一个比记住所有引用测试更好的解决方案。

一个解决方案

为了避免这样的问题,您需要从一开始就编写更好的测试。如果您忽略了被测试类应该处理错误或null值的情况,那么您就只有不完整的测试。这就像没有测试类的所有函数一样。
后期添加这些测试很困难。所以要早点开始并且对测试进行广泛的覆盖。
正如其他用户所提到的 - 代码覆盖率会显示出一些未经测试的情况。但是缺少错误处理代码和相应的测试不会出现在代码覆盖率中。(代码覆盖率达到100%并不意味着您没有漏掉任何东西。) 因此,请编写良好的测试:假设外部世界是恶意的。这不仅包括传递错误参数(例如null值)。您的模拟对象也是外部世界的一部分。传递null和异常,并观察您的类是否按预期处理它们。
如果您决定将null视为有效值,则这些测试将在以后失败(因为缺少异常)。因此,您将得到一个故障列表来进行修复。

因为每个调用类处理错误或null的方式不同 - 这不是可以避免的重复代码。不同的处理需要不同的测试。


提示:保持您的模拟简单且干净。将期望的返回值移到测试方法中。(您的模拟可以简单地将它们传回。)避免在模拟中进行决策测试。


感谢您的回答。问题不在于使用模拟的类没有测试 null。它很可能已经测试了,但是当接收到 null 时预期的结果发生了变化。 - Yishai

0
如果我理解问题正确的话,您有一个使用模型的业务对象。有一个测试用于BO和Model之间的交互(Test A),还有另一个测试用于测试模型和数据库之间的交互(Test B)。Test B更改为返回一个对象,但这个更改不会影响Test A,因为Test A的模型是模拟的。
我唯一能想到的让Test A在Test B更改时失败的方法是在Test A中不模拟模型,并将两个测试合并为一个测试,但这样做不好,因为您将测试太多内容(而且您正在使用不同的框架)。
如果您在编写测试时了解此依赖关系,我认为可以接受的解决方案是在每个测试中留下注释,描述依赖关系以及如果其中一个更改,则需要更改另一个。无论如何,当您进行重构时,都必须更改Test B,当前测试将在您进行更改后立即失败。

0
针对特定场景,您正在更改方法的返回类型,这将在编译时被捕获。如果没有这样做,它将出现在代码覆盖率中(正如Aaron所提到的)。即使如此,您也应该有自动化的功能测试,在提交检查后不久运行。话虽如此,我会进行自动化的冒烟测试,因此在我的情况下,这些测试可以发现问题 :)。
除了上述考虑之外,您仍然有两个重要因素在初始场景中起作用。您希望给单元测试代码与其他代码同等的关注,这意味着希望保持DRY原则。如果您正在进行TDD,那么这甚至会将这种关注推向首要设计。如果您不是这样做的,另一个相反的因素是YAGNI,您不希望在代码中涉及每个(不)可能的情况。因此,对于我来说,如果我的测试告诉我我错过了什么,我会再次检查测试是否正确,并继续进行更改。我确保不会在我的测试中进行“如果”场景,因为这是一个陷阱。

感谢回复。我正在进行TDD,但最终两个部分并没有耦合(这不是模拟的本意吗?)。要进行完整集成级别的TDD需要一个内部EJB容器类型的测试。这是可能的,但不知怎么的,总觉得应该有更好的方法。 - Yishai

-1

你的问题相当令人困惑,大量的文字并没有帮助到我。

但是我在快速阅读中提取的含义对我来说并没有多少意义,因为你希望非合同变更如何影响模拟工作。

模拟是使您能够集中测试系统的特定部分的工具。模拟部分将始终以指定的方式工作,测试可以专注于测试它应该具有的特定逻辑。因此,您不会受到无关逻辑、延迟问题、意外数据等的影响。

您可能会有一些独立的测试,检查另一个上下文中的模拟功能。

关键是,模拟接口与实际的实现之间不应该存在任何连接。这根本没有任何意义,因为您正在模拟合同,并为其提供自己的实现。


问题的关键在于合同将会随着不断发展的设计而改变,如何确保双方都认识到需要进行更改。 - Yishai

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接