如何避免使用Mocks重复逻辑

时间:2009-03-13 16:03:06

标签: unit-testing mocking code-duplication

我遇到了以下挑战,但我没有找到一个好的答案。我正在使用Mocking框架(在本例中为JMock),以允许单元测试与数据库代码隔离。我正在模拟对涉及数据库逻辑的类的访问,并使用DBUnit单独测试数据库类。

我遇到的问题是我注意到逻辑在概念上在多个地方重复的模式。例如,我需要检测数据库中的值不存在,因此在这种情况下我可能会从方法返回null。所以我有一个数据库访问类来进行数据库交互,并适当地返回null。然后我有业务逻辑类,它从模拟中接收null,然后测试如果值为null则适当地执行。

现在如果将来行为需要更改并返回null不再合适,比如因为状态变得更加复杂,所以我需要返回一个报告值不存在的对象,以及一些来自数据库的其他事实。

现在,如果我在这种情况下将数据库类的行为更改为不再返回null,那么业务逻辑类仍然会起作用,并且该bug只会在QA中捕获,除非有人记住耦合,或者正确地遵循了该方法的用法。

我失去了一些东西,我必须有一种更好的方法来避免这种概念上的重复,或者至少要对它进行测试,这样如果它发生变化,那么变化没有传播的事实就会失败。测试

有什么建议吗?

更新:

让我试着澄清一下我的问题。我正在考虑代码何时会随着时间的推移而发展,如何确保集成不会在通过模拟代表的模拟和实际实现测试的类之间中断。

例如,我刚才有一个案例,我有一个最初创建的方法,并且没有期望空值,所以这不是对真实对象的测试。然后,在某些情况下,类的用户(通过模拟测试)被增强以传入null作为参数。在破坏的集成上,因为真正的类没有测试为null。现在,在构建这些类时,这并不是什么大不了的事,因为你在构建时测试两端,但如果设计需要在两个月之后发展,当你倾向于忘记细节时,你将如何测试之间的交互这两组对象(通过模拟与实际实现测试的对象)?

潜在的问题似乎是重复问题(这违反了DRY原则),期望实际上保留在两个地方,虽然这种关系是概念性的,没有实际的重复代码。

[在Aaron Digulla对他的回答进行第二次编辑后编辑]:

是的,这正是我正在做的事情(除了在通过DBUnit测试并在测试期间与数据库交互的类中与DB进行进一步交互,但它是相同的想法) 。所以现在,我们需要修改数据库行为,以便结果不同。使用模拟的测试将继续通过,除非1)有人记得或2)它在集成中断。因此,数据库的存储过程返回值(比如)基本上在模拟的测试数据中重复。现在困扰我的重复是逻辑是重复的,它是对DRY的微妙违反。它可能就是这样(毕竟有一个集成测试的原因),但我觉得我错过了一些东西。

[开始赏金时编辑]

阅读与Aaron的互动可以解决问题,但我真正想要的是如何避免或管理明显重复的一些见解,以便真实班级的行为发生变化在与模拟交互的单元测试中,作为破坏的东西。显然,这不会自动发生,但可能有一种方法可以正确设计场景。

[授予赏金时的编辑]

感谢所有花时间回答问题的人。获胜者告诉我一些关于如何考虑在两层之间传递数据的新内容,并首先得到答案。

11 个答案:

答案 0 :(得分:4)

你从根本上要求不可能的事情。当您更改外部资源的行为时,您要求您的单元测试预测并通知您。没有编写测试来产生新行为,他们怎么知道?

您所描述的是添加一个必须进行测试的全新状态 - 而不是null结果,现在有一些对象来自数据库。你的测试套件怎么可能知道测试对象的预期行为应该是针对一些新的随机对象?你需要写一个新的测试。

正如你评论的那样,模拟不是“行为不端”。模拟正在做你准备做的事情。规范改变的事实对模拟没有影响。此方案中唯一的问题是实施更改的人忘记更新单元测试。我实际上不太清楚为什么你认为有任何重复的问题。

向系统添加一些新的返回结果的编码器负责添加单元测试来处理这种情况。如果该代码也100%确定无法现在可能返回null结果,那么他也可以删除旧的单元测试。但你为什么要这样?单元测试在接收到null结果时正确描述了被测对象的行为。如果将系统的后端更改为某个返回null的新数据库,会发生什么?如果规范改回到返回null怎么办?您可以保留测试,因为就您的对象而言,它可以从外部资源中获得任何回报,它应该优雅地处理每个可能的情况。

模拟的全部目的是将测试与实际资源分离。它不会自动阻止您将错误引入系统。如果您的单元测试准确地描述了它收到null时的行为,那太好了!但是这个测试不应该对任何其他状态有任何了解,当然不应该以某种方式告知外部资源将不再发送空值。

如果您正在进行适当的,松散耦合的设计,您的系统可以拥有您可以想象的任何后端。您不应该考虑使用单个外部资源编写测试。如果您添加了一些使用真实数据库的集成测试,那么您可能会感到更高兴,从而消除了模拟层。这对于进行构建或健全/烟雾测试来说总是一个好主意,但通常会阻碍日常开发。

答案 1 :(得分:4)

你在这里没有遗漏任何东西。这是使用模拟对象进行单元测试的一个弱点。听起来你正确地将你的单元测试分解成合理大小的单位。这是件好事;在“单元”测试中找人测试太多是很常见的。

不幸的是,当您在此粒度级别进行测试时,单元测试不会涵盖协作对象之间的交互。您需要进行一些集成测试或功能测试来涵盖这一点。我真的不知道比这更好的答案。

有时在单元测试中使用真正的协作者而不是模拟是很实际的。例如,如果您是单元测试数据访问对象,则使用单元测试中的真实域对象而不是模拟通常很容易设置和执行。反过来通常不正确 - 数据访问对象通常需要数据库连接,文件或网络连接,并且设置相当复杂和耗时;在单元测试域对象时,使用真实数据对象会将需要几微秒的单位测试转换成需要数百或数千毫秒的单位测试。

总结一下:

  1. 编写一些集成/功能测试以捕获协作对象的问题
  2. 并非总是需要模仿合作者 - 使用你的最佳判断

答案 2 :(得分:2)

单位测试无法告诉您何时方法突然有一小组可能的结果。这就是代码覆盖的目的:它会告诉你代码不再执行了。这反过来将导致在应用程序层中发现死代码。

[编辑]基于评论:模拟必须做任何事情,但允许实例化被测试的类并允许收集其他信息。特别是,它必须永远影响你想要测试的结果。

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

我同意,需要一段时间才能适应。这是我的工作:

  • 我有测试检查SQL是否有效。每个SQL对静态测试数据库执行一次,我验证返回的数据是我期望的。
  • 所有其他测试都使用返回预定义结果的模拟DB连接器运行。我喜欢通过对数据库运行代码来获取这些结果,并在某处记录主键。然后我编写了一个工具,它接受这些主键并将带有模拟的Java代码转储到System.out。这样,我可以非常快速地创建新的测试用例,测试用例将反映“真相”。

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

答案 3 :(得分:2)

您的数据库抽象使用null表示“未找到结果”。忽略在对象之间传递null是个坏主意的事实,当你想测试什么都没有找到时,测试不应该使用那个空文字。相反,使用常量或test data builder,以便您的测试仅引用在对象之间传递的信息,而不是如何表示该信息。然后,如果您需要更改数据库层表示“未找到结果”(或您的测试所依赖的任何信息)的方式,那么您在测试中只有一个位置可以更改它。

答案 4 :(得分:1)

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

问题

当然,您的大多数更改都会被测试捕获 但是有一些场景的子集,你的测试不会失败 - 尽管它应该:

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

您的方法结果一度预计永远不会是null。更改后,您可能会记得修复相应的测试。到目前为止一切都很好。

您运行测试 - 全部通过

但是随着时间的推移你忘记了某些事情......模拟永远不会返回null。因此,使用模拟的n个类的测试不会测试null

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

显然你必须修改你的其他测试。但是没有失败的工作。所以你需要一个比记住所有引用测试更好的解决方案。

解决方案

为了避免这样的问题,你必须从头开始编写更好的测试。如果您错过了测试类应该处理错误或null值的情况,您只需要不完整的测试。这就像没有测试你班级的所有功能一样。

以后很难添加。 - 所以尽早开始并对你的测试进行广泛的研究。

正如其他用户所提到的 - 代码覆盖率揭示了一些未经测试的案例。但缺少错误处理代码缺少相应的测试将不会出现在代码覆盖率中。 (代码覆盖率100%并不意味着您没有遗漏某些东西。)

所以写好测试:假设外部世界是恶意的。这不仅包括传递错误参数(如null值)。 你的模拟也是外部世界的一部分。通过null和例外 - 并观察你的班级按预期处理它们。

如果您认为null是有效值,则这些测试稍后会失败(因为缺少例外)。 所以你得到了一份无法合作的清单。

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


提示:让您的模拟简单干净。将预期的返回值移动到测试方法。 (你的模拟器可以简单地将它们传回去。)避免在模拟中测试决策。

答案 5 :(得分:1)

以下是我理解你的问题的方法:

您正在使用实体的模拟对象来使用JMock测试应用程序的业务层。您还使用DBUnit测试DAO层(应用程序和数据库之间的接口),并传递填充了一组已知值的实体对象的实际副本。因为您使用了两种不同的方法来准备测试对象,所以您的代码违反了DRY,并且随着代码的更改,您的测试可能会与实际情况不同步。

Folwer说......

它不完全相同,但它确实让我想起了Martin Fowler的Mocks Aren't Stubs文章。我认为JMock路由是 mockist 方式,而'真实对象'路由是 classicist 方式来执行测试。

在解决这个问题时尽可能做DRY的一种方法是更多的是古典主义者,然后是 mockist 。也许您可以在测试中妥协并使用bean对象的真实副本。

用户制作者以避免重复

我们在一个项目上所做的是为每个业务对象创建 Makers 。制造商包含静态方法,这些方法将构造给定实体对象的副本,并使用已知值填充。然后,无论您需要哪种对象,都可以调用该对象的制造商并获取具有已知值的副本以用于测试。如果该对象具有子对象,则您的制作者将为子对象调用制作者以便从上到下构建它,并且您将根据需要获得尽可能多的完整对象图。您可以将这些制造商对象用于所有测试 - 在测试DAO层时将它们传递给数据库,并在测试业务服务时将它们传递给服务调用。因为制造商是可重复使用的,所以它是一种相当干燥的方法。

然而,您仍需要使用JMock的一件事是在测试服务层时模拟DAO层。如果您的服务调用DAO,则应确保使用模拟注入它。但是你仍然可以使用你的Makers - 在设置你的期望时,只需确保你的模拟DAO使用Maker为相关实体对象传回预期结果。这样我们仍然没有违反DRY。

编写良好的测试会在代码更改时通知您

我最后建议避免代码随时间变化的问题是总是有一个测试来解决空输入问题。假设您第一次创建方法时,空值是不可接受的。您应该有一个测试,用于验证如果使用null则抛出异常。如果稍后,null变为可接受,则应用程序代码可能会更改,以便以新方式处理空值,并且不再抛出异常。当发生这种情况时,您的测试将开始失败,并且您将有一个“抬头”,事情不同步。

答案 6 :(得分:1)

你只需要下定决心,返回null是外部API的一个预期部分,或者它是一个实现细节。

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

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

外部POV是否有意义,这个东西返回NULL或者这是一个方便的结果,因为在客户端可以直接假设这个NULL的含义? NULL应该表示void / nix / nada / unavailable,没有任何其他含义。

如果你打算稍后对这个条件进行粒化,那么你应该将NULL检查包装成一个返回信息异常,枚举或明确命名的bool的东西。

编写单元测试的一个挑战是,即使是第一次编写的单元测试也应该反映最终产品中的完整API。您需要可视化完整的API,然后针对此进行编程。

此外,您需要在单元测试代码中保持与生产代码中相同的规则,避免重复和功能嫉妒。

答案 7 :(得分:0)

对于特定方案,您将更改将在编译时捕获的方法的返回类型。如果没有,它将出现在代码覆盖范围内(如Aaron所述)。即使这样,您也应该进行自动化功能测试,这些测试将在办理登机手续后立即运行。也就是说,我做了自动烟雾测试,所以在我的情况下,那些会发现:)。

如果不考虑上述情况,您仍然会在初始场景中扮演两个重要因素。您希望为单元测试代码提供与其余代码相同的注意力,这意味着要保持它们干燥是合理的。如果您正在进行TDD,那么首先会将这种担忧推到您的设计中。如果您不参与其中,另一个相反的因素是YAGNI,您不希望在代码中获得每个(不)可能的场景。所以,对我而言,如果我的测试告诉我我错过了什么,我会仔细检查测试是否正常并继续进行更改。我确保在我的测试情况下不要做什么,因为它是一个陷阱。

答案 8 :(得分:0)

如果我正确理解了这个问题,那么您有一个使用模型的业务对象。对BO和Model(测试A)之间的交互进行了测试,还有另一个测试模型和数据库之间的交互测试(测试B)。测试B更改以返回对象,但该更改不会影响测试A,因为测试A的模型被模拟。

我看到让测试A在测试B发生变化时失败的唯一方法是不在测试A中模拟模型并将两者合并为一个单独的测试,这是不好的,因为你将测试太多(和你正在使用不同的框架。)

如果您在编写测试时知道这种依赖关系,我认为一个可接受的解决方案是在每个描述依赖关系的测试中留下注释,如果一个更改,您需要更改另一个。无论如何,您必须在重构时更改测试B,当您进行更改时,当前测试将失败。

答案 9 :(得分:-1)

你的问题很混乱,文字的数量并没有多大帮助。

但是我可以通过快速阅读提取的含义对我来说没什么意义,因为你想要一个非契约变化的变化会影响模拟的工作方式。

Mocking是您专注于测试系统特定部分的推动因素。被模拟的部分将始终以指定的方式工作,测试可以集中精力测试它应该具体的逻辑。因此,您不会受到不相关的逻辑,延迟问题,意外数据等的影响。

您可能会在另一个上下文中检查单独的测试功能。

关键是,模拟接口与其实际实现之间不应存在任何连接。它没有任何意义,因为你是在嘲笑合同并且给它一个你自己的实现。

答案 10 :(得分:-2)

我认为你的问题违反了Liskov替代原则:

子类型必须可替代其基本类型

理想情况下,你会有一个类,它取决于抽象。一个抽象,说“为了能够工作,我需要一个带有这个参数的方法的实现,返回这个结果,如果我做错了什么,就把这个异常抛给我”。这些都将在您依赖的接口上定义,可以通过编译时限制或通过注释来定义。

从技术上讲,你似乎依赖于抽象,但在你告诉的场景中,你并不真正依赖抽象,你实际上依赖于一个实现。你说“如果这种方法改变了它的行为,它的用户就会破坏,我的测试永远不会知道”。在单元测试级别,你是对的。但在合同层面,以这种方式改变行为是错误的。因为通过更改方法,您明显违反了方法与其调用者之间的合同。

为什么要更改方法?很明显,该方法的调用者现在需要不同的行为。因此,您要做的第一件事不是更改方法本身,而是更改客户所依赖的抽象或合同。他们必须先改变并开始使用新合同:“好的,我的需求已经改变,我不再希望这种方法返回,在这个特定的场景中,这个接口的实现者必须返回它”。因此,您可以更改界面,根据需要更改界面的用户,这包括更新测试,最后您要做的是更改传递给客户的实际实施。这样,您就不会遇到您所说的错误。

所以,

class NeedsWork(IWorker b) { DoSth() { b.Work() }; }
...
AppBuilder() { INeedWork GetA() { return new NeedsWork(new Worker()); } }
  1. 修改IWorker,以反映NeedsWork的新需求。
  2. 修改DoSth,使其适用于满足其新需求的新抽象。
  3. 测试NeedsWork并确保它适用于新行为。
  4. 更改您为IWorker提供的所有实现(此方案中的Worker)(您现在首先尝试执行此操作)。
  5. 测试工作者,以满足新的期望。
  6. 似乎很可怕,但在现实生活中,这对于微小的变化来说是微不足道的,对于巨大的变化来说是痛苦的,因为它实际上必须是。