【问题标题】:How to avoid duplicate logic with Mocks如何使用 Mocks 避免重复逻辑
【发布时间】:2010-10-13 04:56:39
【问题描述】:

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

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

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

现在,如果我将数据库类的行为更改为在这种情况下不再返回 null,那么业务逻辑类似乎仍然可以正常工作,并且该错误只会在 QA 中发现,除非有人记得耦合,或者正确遵循该方法的用法。

我觉得我错过了一些东西,必须有更好的方法来避免这种概念上的重复,或者至少对其进行测试,以便如果它发生变化,那么变化未被传播的事实会使一个单元失败测试。

有什么建议吗?

更新:

让我试着澄清我的问题。我在考虑代码何时随时间演变,如何确保通过模拟测试的类与模拟所代表的类的实际实现之间的集成不会中断。

例如,我刚刚有一个案例,我有一个最初创建的方法并且不期望 null 值,所以这不是对真实对象的测试。然后,该类的用户(通过模拟测试)得到增强,可以在某些情况下将 null 作为参数传递。在集成中断时,因为未对真实类进行空值测试。现在在开始构建这些类时,这没什么大不了的,因为您在构建时正在测试两端,但是如果设计需要在两个月后演变,而您往往会忘记细节,您将如何测试它们之间的交互这两组对象(通过模拟测试与实际实现测试的对象)?

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

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

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

[编辑开始赏金]

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

[编辑奖励赏金]

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

【问题讨论】:

  • 你的问题有点混乱。您可能需要对其进行编辑。

标签: unit-testing mocking code-duplication


【解决方案1】:

单元测试无法告诉您何时某个方法突然产生了一组较小的可能结果。这就是代码覆盖的用途:它会告诉您代码不再执行。这反过来又会导致在应用层发现死代码。

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

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

我同意,这需要一些时间来适应。这是我的工作:

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

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

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

【讨论】:

  • 感谢您的回答。据我所知,代码都包含在更改中。会有一些小的未发现代码,其中注入了真实的类来代替模拟,但这并没有改变。模拟行为不端,因为它设定了真实类不再满足的期望。
  • 感谢编辑,但我不明白。如果我模拟数据库调用,则模拟必须返回测试数据。怎么能不影响测试结果呢?
【解决方案2】:

对于特定场景,您正在更改方法的返回类型,这将在编译时被捕获。如果没有,它将出现在代码覆盖率上(如 Aaron 所述)。即使这样,您也应该进行自动化功能测试,这些测试将在签入后不久运行。也就是说,我进行了自动烟雾测试,所以在我的情况下,那些会发现:)。

如果不考虑上述情况,您仍然有两个重要因素在初始场景中发挥作用。您希望对单元测试代码给予与其他代码相同的关注,这意味着保持它们 DRY 是合理的。如果你在做 TDD,那甚至会把这个问题放在你的设计中。如果您对此不感兴趣,则涉及的另一个相反因素是 YAGNI,您不希望在代码中出现所有(不)可能的情况。所以,对我来说,它会是:如果我的测试告诉我我遗漏了一些东西,我会仔细检查测试是否正常并继续进行更改。我确保不要在我的测试中做假设场景,因为这是一个陷阱。

【讨论】:

  • 感谢您的回复。我在做 TDD,但最后这两个部分没有耦合(毕竟这不是 mock 的用途)?要进行完全集成级别的 TDD,需要进行 EJB 容器内类型测试。可能,但不知何故,似乎应该有更好的方法。
【解决方案3】:

您的问题非常令人困惑,而且文字数量并没有完全帮助。

但是我可以通过快速阅读来提取的含义对我来说意义不大,因为您希望非合同更改更改会影响模拟的工作方式。

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

您可能会有不同数量的测试在另一个上下文中检查模拟功能。

关键是,模拟接口和实际实现之间根本不应该存在任何联系。它只是没有任何意义,因为你在嘲笑合同并给它一个你自己的实现。

【讨论】:

  • 问题的重点是合同会在不断发展的设计中发生变化,以及如何确保双方都认识到需要改变。
【解决方案4】:

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

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

正如您评论的那样,模拟不是“行为不端”。模拟正在做你设置它做的事情。规范更改的事实对模拟没有影响。这种情况下的唯一问题是实施更改的人忘记更新单元测试。实际上,我不太确定您为什么认为存在重复问题。

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

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

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

【讨论】:

  • 感谢您的详细回答,您可能确实是对的,我要求不可能,或者更正式地说“这就是集成测试的目的”。但是我的问题的症结不是“一些外部资源”发生了变化,而是由于重构而导致设计更改的某些部分(即演变),但是被嘲笑的部分对于单元测试来说太重了,所以它可以不仅仅是一起测试。
  • 当你写“模拟的全部目的是将你的测试与真实资源分离”时,我的问题来自模拟的观点,即“你只模拟你拥有的类型”mockobjects.com/2008/11/only-mock-types-you-own-revisited.html
  • 我当然同意这一点,但我相信你在概念上试图在你的模拟上建立一个链接,这与它们的设计目的完全相反;)
【解决方案5】:

我认为你的问题违反了里氏替换原则:

子类型必须可以替代它们的基类型

理想情况下,您应该有一个依赖于抽象的类。一个抽象说“为了能够工作,我需要这个方法的实现,它接受这个参数,返回这个结果,如果我做错了,就会抛出这个异常”。这些都将在您依赖的接口上定义,无论是通过编译时间限制还是通过 cmets。

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

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

所以,

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. Test Worker 使其满足新的期望。

看起来很可怕,但在现实生活中,这对于微小的变化来说是微不足道的,而对于巨大的变化来说是痛苦的,事实上,它必须如此。

【讨论】:

  • 感谢您的回答。但是,答案并没有真正解决像 JMock 这样的框架,在这种框架中,您并没有真正创建实现明确定义的合同的具体类。这个想法是测试特定行为,而不是整个合同,从而使测试变得更小。
【解决方案6】:

您不会在这里遗漏任何东西。这是使用模拟对象进行单元测试的一个弱点。听起来您正在正确地将单元测试分解为合理大小的单元。这是一件好事;在“单元”测试中发现人们测试过多的情况更为常见。

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

有时在单元测试中使用真正的协作者而不是模拟者是很实用的。例如,如果您正在对数据访问对象进行单元测试,那么在单元测试中使用真实的域对象而不是模拟通常很容易设置并执行得一样好。反过来往往不正确——数据访问对象通常需要数据库连接、文件或网络连接,而且设置起来非常复杂且耗时;在对域对象进行单元测试时使用真实的数据对象会将需要几微秒的单元测试变成需要数百或数千毫秒的单元测试。

总结一下:

  1. 编写一些集成/功能测试来发现协作对象的问题
  2. 并不总是需要嘲笑合作者 - 使用您的最佳判断

【讨论】:

    【解决方案7】:

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

    问题

    当然,您的大部分更改都会被测试捕获。
    但是在某些情况下,您的测试不会失败 - 尽管它应该:

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

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

    您运行测试 - 全部通过

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

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

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

    解决方案

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

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

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

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

    如果您决定 null 是一个有效值 - 这些测试稍后将失败(因为缺少异常)。 所以你会得到一个失败的列表。

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


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

    【讨论】:

    • 感谢您的回答。问题不在于,尽管使用模拟的类没有测试空值。可能有,但收到 null 时的预期结果发生了变化。
    【解决方案8】:

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

    【讨论】:

    • 这是一个很好的建议,以一种结合模拟和真实类数据构建的方式抽象数据表示。我必须尝试一下,看看这能解决多少问题。
    【解决方案9】:

    我是这样理解你的问题的:

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

    追随者说...

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

    在解决这个问题时尽可能保持干爽的一种方法是成为一个 classicist 而不是 mockist。也许您可以在测试中妥协并使用您的 bean 对象的真实副本。

    用户制造者避免重复

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

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

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

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

    【讨论】:

      【解决方案10】:

      您只需决定返回 null 是外部 API 的预期部分还是实现细节。

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

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

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

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

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

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

      【讨论】:

        【解决方案11】:

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

        当测试 B 更改时,我认为使测试 A 失败的唯一方法是不在测试 A 中模拟模型并将两者组合成一个测试,这不好,因为您将测试太多(并且您正在使用不同的框架)。

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

        【讨论】:

          猜你喜欢
          • 2021-11-27
          • 2020-08-12
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2015-02-12
          相关资源
          最近更新 更多